From d272ae0bd3653c52397700c4336c365218830e13 Mon Sep 17 00:00:00 2001 From: debu-2 Date: Fri, 2 Oct 2026 21:59:13 +0530 Subject: [PATCH] WA template --- backend/app/agent_message_service.py | 277 ++++++++++++++++++ backend/app/agent_templates_seed.py | 63 ++++ backend/app/main.py | 7 + backend/app/models.py | 82 ++++++ backend/app/routes_agent_messages.py | 106 +++++++ backend/app/routes_agent_templates.py | 144 +++++++++ frontend/src/App.tsx | 4 + .../features/agent/AgentTemplateDocsPage.tsx | 91 ++++++ .../src/features/agent/AgentTemplatesPage.tsx | 248 ++++++++++++++++ .../src/features/develop/ApiExplorerPage.tsx | 140 ++++++++- frontend/src/index.css | 82 ++++++ frontend/src/lib/api.ts | 120 ++++++++ 12 files changed, 1359 insertions(+), 5 deletions(-) create mode 100644 backend/app/agent_message_service.py create mode 100644 backend/app/agent_templates_seed.py create mode 100644 backend/app/routes_agent_messages.py create mode 100644 backend/app/routes_agent_templates.py create mode 100644 frontend/src/features/agent/AgentTemplateDocsPage.tsx create mode 100644 frontend/src/features/agent/AgentTemplatesPage.tsx diff --git a/backend/app/agent_message_service.py b/backend/app/agent_message_service.py new file mode 100644 index 0000000..a77073a --- /dev/null +++ b/backend/app/agent_message_service.py @@ -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_", + "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.", + ], + } diff --git a/backend/app/agent_templates_seed.py b/backend/app/agent_templates_seed.py new file mode 100644 index 0000000..6b3cf67 --- /dev/null +++ b/backend/app/agent_templates_seed.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index ad672b7..6cc2254 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py index 2b42181..eb41407 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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).""" diff --git a/backend/app/routes_agent_messages.py b/backend/app/routes_agent_messages.py new file mode 100644 index 0000000..c9cbc31 --- /dev/null +++ b/backend/app/routes_agent_messages.py @@ -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_", + "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") diff --git a/backend/app/routes_agent_templates.py b/backend/app/routes_agent_templates.py new file mode 100644 index 0000000..b93b74f --- /dev/null +++ b/backend/app/routes_agent_templates.py @@ -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, + ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c5c656c..450aa5c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,8 @@ import { AnalyticsPage } from './features/analytics/AnalyticsPage' import { WhatsAppSetupPage } from './features/whatsapp/WhatsAppSetupPage' import { UrmiOtpPage } from './features/agent/UrmiOtpPage' import { UrmiOtpDocsPage } from './features/agent/UrmiOtpDocsPage' +import { AgentTemplatesPage } from './features/agent/AgentTemplatesPage' +import { AgentTemplateDocsPage } from './features/agent/AgentTemplateDocsPage' import { KycPage } from './features/compliance/KycPage' import { DltPage } from './features/compliance/DltPage' import { ComplianceGate } from './features/compliance/ComplianceProvider' @@ -68,6 +70,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/agent/AgentTemplateDocsPage.tsx b/frontend/src/features/agent/AgentTemplateDocsPage.tsx new file mode 100644 index 0000000..b162d23 --- /dev/null +++ b/frontend/src/features/agent/AgentTemplateDocsPage.tsx @@ -0,0 +1,91 @@ +import { ArrowLeft, Copy } from 'lucide-react' +import { useEffect, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { agentTemplatesApi, type AgentTemplateDocumentation } from '../../lib/api' +import { API_BASE_URL } from '../../lib/apiBase' + +export function AgentTemplateDocsPage() { + const { slug = '' } = useParams() + const navigate = useNavigate() + const [docs, setDocs] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [copied, setCopied] = useState(false) + + useEffect(() => { + agentTemplatesApi.documentation(slug) + .then(setDocs) + .catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load documentation')) + .finally(() => setLoading(false)) + }, [slug]) + + if (loading) return
Loading API documentation…
+ if (!docs) return

{error}

+ + const requestExample = JSON.stringify(docs.api.example, null, 2) + const curlExample = [ + `curl -X POST '${API_BASE_URL}${docs.api.path}' \\`, + ` -H 'Authorization: Bearer urmi_live_' \\`, + ` -H 'Content-Type: application/json' \\`, + ` -d '${requestExample.replace(/'/g, `'\\''`)}'`, + ].join('\n') + + async function copyExample() { + await navigator.clipboard.writeText(requestExample) + setCopied(true) + window.setTimeout(() => setCopied(false), 2000) + } + + return ( +
+ +
+ UrmiAI · {docs.template.slug} +

{docs.template.name}

+

{docs.template.description}

+
+ +
+
+

Send agent WhatsApp message

{docs.api.summary}

+ {docs.api.method} +
+
Endpoint{API_BASE_URL}{docs.api.path}
+
+ Authentication + {docs.api.authentication.header}: {docs.api.authentication.format} +
+
+ {Object.entries(docs.api.parameters).map(([name, description]) => ( +
{name}{description}
+ ))} +
+
+ Request body + +
+
{requestExample}
+
Example cURL
+
{curlExample}
+
+ +
+
+

Template preview

Approved Meta template relayed by Urmi.

+
+
+ {docs.template.header_text && {docs.template.header_text}} +

{docs.template.body_preview}

+ {docs.template.footer_text && {docs.template.footer_text}} + {docs.template.button_label && {docs.template.button_label}} +
+
    + {docs.notes.map((note) =>
  • {note}
  • )} +
+ Try in API Explorer +
+
+ ) +} diff --git a/frontend/src/features/agent/AgentTemplatesPage.tsx b/frontend/src/features/agent/AgentTemplatesPage.tsx new file mode 100644 index 0000000..acf3c48 --- /dev/null +++ b/frontend/src/features/agent/AgentTemplatesPage.tsx @@ -0,0 +1,248 @@ +import { ArrowRight, Bot, Code2, Play, X } from 'lucide-react' +import { useEffect, useState, type FormEvent } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { agentTemplatesApi, type AgentMessageTemplate } from '../../lib/api' + +function TestModal({ + template, + onClose, + onSent, +}: { + template: AgentMessageTemplate + onClose: () => void + onSent: () => void +}) { + const [to, setTo] = useState('') + const [customerName, setCustomerName] = useState('') + const [reportId, setReportId] = useState('') + const [error, setError] = useState('') + const [sending, setSending] = useState(false) + + async function submit(event: FormEvent) { + event.preventDefault() + setError('') + setSending(true) + try { + await agentTemplatesApi.test(template.slug, { + to: to.trim(), + customer_name: customerName.trim(), + report_id: reportId.trim() || undefined, + }) + onSent() + onClose() + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'Unable to send test message') + } finally { + setSending(false) + } + } + + return ( +
+
event.stopPropagation()}> +
+

Test {template.name}

+ +
+

+ One console test per day. Urmi sends from the Urmi AI WhatsApp business number on your behalf. +

+
+ + + + {error &&

{error}

} +
+
+ + +
+
+
+ ) +} + +export function AgentTemplatesPage() { + const navigate = useNavigate() + const [templates, setTemplates] = useState([]) + const [testStatus, setTestStatus] = useState>({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [testTemplate, setTestTemplate] = useState(null) + const [actionError, setActionError] = useState('') + + async function load() { + const items = await agentTemplatesApi.list() + setTemplates(items) + const statuses = await Promise.all( + items + .filter((item) => item.subscribed) + .map(async (item) => { + try { + const status = await agentTemplatesApi.testStatus(item.slug) + return [item.slug, status] as const + } catch { + return [item.slug, { allowed: true }] as const + } + }), + ) + setTestStatus(Object.fromEntries(statuses)) + } + + useEffect(() => { + load() + .catch(() => setError('Unable to load agent templates')) + .finally(() => setLoading(false)) + }, []) + + async function toggleSubscribe(template: AgentMessageTemplate) { + setActionError('') + try { + if (template.subscribed) { + await agentTemplatesApi.unsubscribe(template.slug) + } else { + await agentTemplatesApi.subscribe(template.slug) + } + await load() + } catch (reason) { + setActionError(reason instanceof Error ? reason.message : 'Unable to update subscription') + } + } + + if (loading) return
Loading agent templates…
+ + return ( +
+
+ Agent · WhatsApp +

UrmiAI agent messages

+

+ Subscribe to approved WhatsApp agent templates. Urmi authenticates your API key and sends messages + from the Urmi AI business number on behalf of your agents. +

+
+ +
+
+
+

Available templates

+

Subscribe to enable API access and console testing.

+
+ +
+ + {templates.length === 0 ? ( +
+ No agent templates yet + Templates will appear here as Urmi adds them to the platform catalog. +
+ ) : ( +
+
+ Template + Preview + Parameters + Status + +
+ {templates.map((template) => { + const status = testStatus[template.slug] + const canTest = template.subscribed && (status?.allowed ?? true) + return ( +
+
+ {template.name} + {template.slug} · Meta: {template.meta_template_name} + {template.description} +
+
+ {template.header_text && {template.header_text}} +

{template.body_preview}

+ {template.footer_text && {template.footer_text}} + {template.button_label && {template.button_label}} +
+
+ {template.parameters.map((param) => ( +
+ {param.name} + {param.description} +
+ ))} +
+
+ + {template.subscribed ? 'Subscribed' : 'Not subscribed'} + + {template.subscribed && status && !status.allowed && status.retry_after && ( + Test again after {new Date(status.retry_after).toLocaleString()} + )} +
+
+ + {template.subscribed && ( + <> + + + + )} +
+
+ ) + })} +
+ )} + {(error || actionError) &&

{error || actionError}

} +
+ +
+
+
+

How it works

+

Urmi acts as your WhatsApp tech provider middle layer.

+
+
+
    +
  1. Subscribe to a template in this console.
  2. +
  3. Create an API key under Develop → API Keys.
  4. +
  5. Call POST /api/v1/agent/messages/send with your Urmi API key.
  6. +
  7. Urmi validates your subscription and relays the message to Meta WhatsApp Cloud API.
  8. +
+ Open API Explorer +
+ + {testTemplate && ( + setTestTemplate(null)} + onSent={() => load().catch(() => undefined)} + /> + )} +
+ ) +} diff --git a/frontend/src/features/develop/ApiExplorerPage.tsx b/frontend/src/features/develop/ApiExplorerPage.tsx index fc8d00c..0e65fa6 100644 --- a/frontend/src/features/develop/ApiExplorerPage.tsx +++ b/frontend/src/features/develop/ApiExplorerPage.tsx @@ -2,10 +2,13 @@ import { CheckCircle2, Copy, MessageSquareText, Phone, Play, Radio, ShieldCheck import { useEffect, useMemo, useState, type ReactNode } from 'react' import { Link } from 'react-router-dom' import { + agentMessagesApi, integrationsApi, messagesApi, numbersApi, voiceApi, + type AgentMessageApiDoc, + type AgentMessageSendResponse, type IntegrationsDocs, type MessageApiDoc, type PhoneNumber, @@ -116,6 +119,7 @@ function normalizeIntegrationDocs(raw: Partial | null): Integr export function ApiExplorerPage() { const [smsDoc, setSmsDoc] = useState(null) + const [agentDoc, setAgentDoc] = useState(null) const [voiceDoc, setVoiceDoc] = useState(null) const [integrationDocs, setIntegrationDocs] = useState(null) const [numbers, setNumbers] = useState([]) @@ -130,25 +134,38 @@ export function ApiExplorerPage() { const [smsError, setSmsError] = useState('') const [smsRunning, setSmsRunning] = useState(false) + const [agentTemplate, setAgentTemplate] = useState('') + const [agentTo, setAgentTo] = useState('') + const [agentCustomerName, setAgentCustomerName] = useState('') + const [agentReportId, setAgentReportId] = useState('') + const [agentResponse, setAgentResponse] = useState(null) + const [agentError, setAgentError] = useState('') + const [agentRunning, setAgentRunning] = useState(false) + const [fromDid, setFromDid] = useState('') const [callTo, setCallTo] = useState('') const [voiceResponse, setVoiceResponse] = useState(null) const [voiceError, setVoiceError] = useState('') const [voiceRunning, setVoiceRunning] = useState(false) - const [copied, setCopied] = useState<'sms' | 'voice' | null>(null) + const [copied, setCopied] = useState<'sms' | 'voice' | 'agent' | null>(null) const [loadError, setLoadError] = useState('') useEffect(() => { Promise.allSettled([ messagesApi.docs(), + agentMessagesApi.docs(), voiceApi.docs(), integrationsApi.docs(), numbersApi.mine(), ]) .then((results) => { - const [smsResult, voiceResult, integrationResult, numbersResult] = results + const [smsResult, agentResult, voiceResult, integrationResult, numbersResult] = results if (smsResult.status === 'fulfilled') setSmsDoc(smsResult.value) + if (agentResult.status === 'fulfilled') { + setAgentDoc(agentResult.value) + setAgentTemplate(agentResult.value.request_body.template?.example ?? 'agent_run_completion') + } if (voiceResult.status === 'fulfilled') setVoiceDoc(normalizeVoiceDocs(voiceResult.value)) if (integrationResult.status === 'fulfilled') { setIntegrationDocs(normalizeIntegrationDocs(integrationResult.value)) @@ -183,6 +200,22 @@ export function ApiExplorerPage() { ].join('\n') }, [apiKey, message, senderId, smsDoc, smsTo, templateId]) + const agentCurl = useMemo(() => { + if (!agentDoc) return '' + const body = JSON.stringify({ + template: agentTemplate || agentDoc.request_body.template.example, + to: agentTo || agentDoc.request_body.to.example, + customer_name: agentCustomerName || agentDoc.request_body.customer_name.example, + report_id: agentReportId || agentDoc.request_body.report_id.example, + }, null, 2) + return [ + `curl -X POST '${API_BASE_URL}${agentDoc.path}' \\`, + ` -H 'Authorization: Bearer ${apiKey || 'urmi_live_'}' \\`, + ` -H 'Content-Type: application/json' \\`, + ` -d '${body.replace(/'/g, `'\\''`)}'`, + ].join('\n') + }, [agentCustomerName, agentDoc, agentReportId, agentTemplate, agentTo, apiKey]) + const voiceCurl = useMemo(() => { if (!voiceDoc) return '' const body = JSON.stringify({ @@ -217,6 +250,25 @@ export function ApiExplorerPage() { } } + async function runAgentMessage() { + setAgentError('') + setAgentResponse(null) + setAgentRunning(true) + try { + const result = await agentMessagesApi.send(apiKey.trim(), { + template: agentTemplate.trim(), + to: agentTo.trim(), + customer_name: agentCustomerName.trim(), + report_id: agentReportId.trim() || undefined, + }) + setAgentResponse(result) + } catch (reason) { + setAgentError(reason instanceof Error ? reason.message : 'Request failed') + } finally { + setAgentRunning(false) + } + } + async function runVoiceCall() { setVoiceError('') setVoiceResponse(null) @@ -235,8 +287,9 @@ export function ApiExplorerPage() { } } - async function copyCurl(kind: 'sms' | 'voice') { - await navigator.clipboard.writeText(kind === 'sms' ? smsCurl : voiceCurl) + async function copyCurl(kind: 'sms' | 'voice' | 'agent') { + const text = kind === 'sms' ? smsCurl : kind === 'agent' ? agentCurl : voiceCurl + await navigator.clipboard.writeText(text) setCopied(kind) window.setTimeout(() => setCopied(null), 2000) } @@ -248,7 +301,7 @@ export function ApiExplorerPage() { ) } - if (!smsDoc && !voiceDoc && !integrationDocs) { + if (!smsDoc && !agentDoc && !voiceDoc && !integrationDocs) { return (
@@ -345,6 +398,83 @@ export function ApiExplorerPage() { )} + {agentDoc && ( + +
+
+
+

{agentDoc.title}

+

{agentDoc.summary}

+
+ {agentDoc.method} +
+ + + + +
+

Request body

+
+ {Object.entries(agentDoc.request_body).map(([key, field]) => ( +
+ +
+ ))} +
+
+ +
+

API key

+ +
+ +
+ + +
+ + {agentError &&

{agentError}

} + {agentResponse && ( +
+ 200 OK +
{JSON.stringify(agentResponse, null, 2)}
+
+ )} + +
+

Example cURL

+
{agentCurl}
+
+
+
+ )} + {voiceDoc && integrationDocs && ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 2269ee3..ae1ea8d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1180,6 +1180,85 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; } .otp-empty { display: grid; justify-items: center; gap: 7px; padding: 42px 20px 25px; color: #8a95a7; text-align: center; } .otp-empty strong { color: #46546a; font-size: 12px; } .otp-empty span { font-size: 10px; } +.agent-templates-page { max-width: 1180px; } +.agent-template-table { margin-top: 18px; } +.agent-template-table-head, +.agent-template-row { + display: grid; + grid-template-columns: minmax(180px, 1.1fr) minmax(220px, 1.2fr) minmax(180px, .9fr) minmax(120px, .7fr) auto; + gap: 14px; + align-items: start; +} +.agent-template-table-head { + padding: 0 10px 9px; + color: #8a95a7; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; +} +.agent-template-row { + padding: 14px 10px; + border-top: 1px solid #edf0f4; +} +.agent-template-row strong, +.agent-template-row small, +.agent-template-row p { + display: block; +} +.agent-template-row strong { color: #35435a; font-size: 12px; } +.agent-template-row small { + margin-top: 4px; + color: #8a95a7; + font-size: 9px; + line-height: 1.4; +} +.agent-template-preview p { + margin: 6px 0; + color: #46546a; + font-size: 11px; + line-height: 1.45; +} +.agent-template-params div { + display: grid; + gap: 4px; + margin-bottom: 8px; +} +.agent-template-params code { + color: #1475ff; + font-size: 10px; +} +.agent-template-params span { + color: #8a95a7; + font-size: 9px; + line-height: 1.4; +} +.agent-template-actions { + display: flex; + flex-direction: column; + gap: 8px; + align-items: stretch; +} +.agent-template-button-chip { + display: inline-flex; + margin-top: 8px; + padding: 6px 10px; + border: 1px solid #dfe5ed; + border-radius: 999px; + color: #1475ff; + font-size: 10px; + font-weight: 600; +} +.agent-template-test-note, +.agent-template-notes { + color: #607089; + font-size: 11px; + line-height: 1.5; +} +.agent-template-notes { + margin: 12px 0 0; + padding-left: 18px; +} +.agent-templates-help { margin-top: 18px; } .otp-project-table { margin-top: 18px; } .otp-project-table-head, .otp-project-row { display: grid; grid-template-columns: minmax(180px, 1.3fr) minmax(140px, .8fr) minmax(120px, .7fr) auto; gap: 14px; align-items: center; } .otp-project-table-head { padding: 0 10px 9px; color: #8a95a7; font-size: 9px; font-weight: 700; text-transform: uppercase; } @@ -1261,6 +1340,9 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; } .api-key-row { grid-template-columns: 1fr; } .otp-map-branches { grid-template-columns: repeat(4, 1fr); } .otp-map-branches::before, .otp-map-branches > div::before { display: none; } + .agent-template-table-head { display: none; } + .agent-template-row { grid-template-columns: 1fr; align-items: start; } + .agent-template-actions { flex-wrap: wrap; } .otp-project-table-head { display: none; } .otp-project-row { grid-template-columns: 1fr; align-items: start; } .templates-table-head { display: none; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ea2df9c..e1dae44 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -420,6 +420,126 @@ export type AdminUrmiOtpProject = UrmiOtpProject & { user: { id: string; name: string; email: string } } +export type AgentTemplateParameter = { + name: string + type: string + required: boolean + description: string + example: string +} + +export type AgentMessageTemplate = { + id: string + slug: string + name: string + description: string + header_text: string | null + body_preview: string + footer_text: string | null + meta_template_name: string + language: string + category: string + parameters: AgentTemplateParameter[] + button_label: string | null + button_url_base: string | null + is_active: boolean + subscribed: boolean +} + +export type AgentTemplateDocumentation = { + template: AgentMessageTemplate + api: { + method: string + path: string + summary: string + authentication: { + type: string + header: string + format: string + alternate_header?: string + } + parameters: Record + example: Record + response_example: Record + } + notes: string[] +} + +export type AgentMessageApiDoc = { + id: string + method: string + path: string + title: string + summary: string + authentication: { + type: string + header: string + format: string + alternate_header?: string + } + request_body: Record + responses: Record }> + notes?: string[] + available_templates?: Array<{ slug: string; name: string; meta_template_name: string }> +} + +export type AgentMessageSendResponse = { + success: boolean + template: string + to: string + customer_name: string + report_id: string + report_url: string | null + wa_message_id: string | null + dispatch_id: string +} + +export const agentTemplatesApi = { + list: () => jsonRequest('/api/v1/agent-templates'), + subscribe: (slug: string) => + jsonRequest(`/api/v1/agent-templates/${slug}/subscribe`, { method: 'POST' }), + unsubscribe: (slug: string) => + jsonRequest(`/api/v1/agent-templates/${slug}/subscribe`, { method: 'DELETE' }), + documentation: (slug: string) => + jsonRequest(`/api/v1/agent-templates/${slug}/docs`), + testStatus: (slug: string) => + jsonRequest<{ template: string; allowed: boolean; retry_after?: string | null; last_tested_at?: string | null }>( + `/api/v1/agent-templates/${slug}/test-status`, + ), + test: (slug: string, payload: { to: string; customer_name: string; report_id?: string }) => + jsonRequest(`/api/v1/agent-templates/${slug}/test`, { + method: 'POST', + body: JSON.stringify(payload), + }), +} + +export const agentMessagesApi = { + docs: () => jsonRequest('/api/v1/agent/messages/docs/send'), + send: ( + apiKey: string, + payload: { template: string; to: string; customer_name: string; report_id?: string }, + ) => + fetch(`${API_BASE_URL}/api/v1/agent/messages/send`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }).then(async (response) => { + if (!response.ok) { + const body = await response.json().catch(() => null) + throw new Error(typeof body?.detail === 'string' ? body.detail : 'Request failed') + } + return response.json() as Promise + }), +} + export const urmiOtpApi = { listProjects: () => jsonRequest('/api/v1/urmiotp/projects'), createProject: (payload: {