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_urmiotp import router as urmiotp_router
from .routes_voice import router as voice_router from .routes_voice import router as voice_router
from .routes_whatsapp import router as whatsapp_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 @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
with Session(engine) as db:
seed_agent_message_templates(db)
with engine.begin() as conn: with engine.begin() as conn:
conn.execute( conn.execute(
text( text(
@ -88,6 +93,8 @@ app.include_router(numbers_router)
app.include_router(integrations_router) app.include_router(integrations_router)
app.include_router(voice_router) app.include_router(voice_router)
app.include_router(whatsapp_router) app.include_router(whatsapp_router)
app.include_router(agent_templates_router)
app.include_router(agent_messages_router)
app.include_router(admin_router) app.include_router(admin_router)

View file

@ -103,6 +103,12 @@ class UserAccount(Base):
sms_delivery_webhooks: Mapped[list["SmsDeliveryWebhook"]] = relationship( sms_delivery_webhooks: Mapped[list["SmsDeliveryWebhook"]] = relationship(
back_populates="user", cascade="all, delete-orphan" 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): 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) 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): class PlatformSetting(Base):
"""Runtime overrides for platform credentials (admin-editable).""" """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,
)

View file

@ -21,6 +21,8 @@ import { AnalyticsPage } from './features/analytics/AnalyticsPage'
import { WhatsAppSetupPage } from './features/whatsapp/WhatsAppSetupPage' import { WhatsAppSetupPage } from './features/whatsapp/WhatsAppSetupPage'
import { UrmiOtpPage } from './features/agent/UrmiOtpPage' import { UrmiOtpPage } from './features/agent/UrmiOtpPage'
import { UrmiOtpDocsPage } from './features/agent/UrmiOtpDocsPage' 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 { KycPage } from './features/compliance/KycPage'
import { DltPage } from './features/compliance/DltPage' import { DltPage } from './features/compliance/DltPage'
import { ComplianceGate } from './features/compliance/ComplianceProvider' import { ComplianceGate } from './features/compliance/ComplianceProvider'
@ -68,6 +70,8 @@ export default function App() {
<Route path="numbers/buy" element={<BuyNumbersPage />} /> <Route path="numbers/buy" element={<BuyNumbersPage />} />
<Route path="numbers" element={<MyNumbersPage />} /> <Route path="numbers" element={<MyNumbersPage />} />
<Route path="voice" element={<VoicePage />} /> <Route path="voice" element={<VoicePage />} />
<Route path="agent/urmiai" element={<AgentTemplatesPage />} />
<Route path="agent/urmiai/templates/:slug/docs" element={<AgentTemplateDocsPage />} />
<Route path="agent/urmiotp" element={<UrmiOtpPage />} /> <Route path="agent/urmiotp" element={<UrmiOtpPage />} />
<Route path="agent/urmiotp/projects/:projectId/docs" element={<UrmiOtpDocsPage />} /> <Route path="agent/urmiotp/projects/:projectId/docs" element={<UrmiOtpDocsPage />} />
<Route path=":section/*" element={<Placeholder />} /> <Route path=":section/*" element={<Placeholder />} />

View file

@ -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<AgentTemplateDocumentation | null>(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 <div className="auth-loading">Loading API documentation…</div>
if (!docs) return <section className="panel"><p className="auth-error">{error}</p></section>
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_<your_api_key>' \\`,
` -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 (
<div className="urmiotp-docs-page">
<button className="text-button otp-back" onClick={() => navigate('/console/agent/urmiai')}>
<ArrowLeft size={14} /> Back to templates
</button>
<div className="account-heading">
<span className="eyebrow">UrmiAI · {docs.template.slug}</span>
<h1>{docs.template.name}</h1>
<p>{docs.template.description}</p>
</div>
<section className="panel otp-api-overview">
<div className="panel-heading">
<div><h2>Send agent WhatsApp message</h2><p>{docs.api.summary}</p></div>
<span className="explorer-method">{docs.api.method}</span>
</div>
<div className="explorer-field-row"><span>Endpoint</span><code>{API_BASE_URL}{docs.api.path}</code></div>
<div className="explorer-field-row">
<span>Authentication</span>
<code>{docs.api.authentication.header}: {docs.api.authentication.format}</code>
</div>
<div className="otp-parameter-list">
{Object.entries(docs.api.parameters).map(([name, description]) => (
<div key={name}><code>{name}</code><span>{description}</span></div>
))}
</div>
<div className="otp-code-heading">
<strong>Request body</strong>
<button className="secondary-button" onClick={copyExample}><Copy size={13} /> {copied ? 'Copied' : 'Copy'}</button>
</div>
<pre className="explorer-code">{requestExample}</pre>
<div className="otp-code-heading"><strong>Example cURL</strong></div>
<pre className="explorer-code">{curlExample}</pre>
</section>
<section className="panel agent-template-docs-preview">
<div className="panel-heading">
<div><h2>Template preview</h2><p>Approved Meta template relayed by Urmi.</p></div>
</div>
<div className="agent-template-preview card">
{docs.template.header_text && <strong>{docs.template.header_text}</strong>}
<p>{docs.template.body_preview}</p>
{docs.template.footer_text && <small>{docs.template.footer_text}</small>}
{docs.template.button_label && <span className="agent-template-button-chip">{docs.template.button_label}</span>}
</div>
<ul className="agent-template-notes">
{docs.notes.map((note) => <li key={note}>{note}</li>)}
</ul>
<Link to="/console/api-explorer" className="secondary-button">Try in API Explorer</Link>
</section>
</div>
)
}

View file

@ -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 (
<div className="modal-overlay" onClick={onClose}>
<form className="modal-card otp-project-modal" onSubmit={submit} onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h2>Test {template.name}</h2>
<button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button>
</div>
<p className="agent-template-test-note">
One console test per day. Urmi sends from the Urmi AI WhatsApp business number on your behalf.
</p>
<div className="otp-project-form">
<label>
<span>Recipient WhatsApp number</span>
<input value={to} onChange={(e) => setTo(e.target.value)} placeholder="919149154337" required />
</label>
<label>
<span>Customer name</span>
<input value={customerName} onChange={(e) => setCustomerName(e.target.value)} placeholder="Suyash" required />
</label>
<label>
<span>Report ID (optional)</span>
<input value={reportId} onChange={(e) => setReportId(e.target.value)} placeholder="run-abc123" />
<small>Used for the dynamic View Report button URL suffix.</small>
</label>
{error && <p className="auth-error">{error}</p>}
</div>
<div className="modal-actions">
<button className="primary-button" disabled={sending}>{sending ? 'Sending…' : 'Send test'}</button>
<button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
</div>
</form>
</div>
)
}
export function AgentTemplatesPage() {
const navigate = useNavigate()
const [templates, setTemplates] = useState<AgentMessageTemplate[]>([])
const [testStatus, setTestStatus] = useState<Record<string, { allowed: boolean; retry_after?: string | null }>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [testTemplate, setTestTemplate] = useState<AgentMessageTemplate | null>(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 <div className="auth-loading">Loading agent templates…</div>
return (
<div className="agent-templates-page">
<div className="account-heading">
<span className="eyebrow">Agent · WhatsApp</span>
<h1><Bot size={24} /> UrmiAI agent messages</h1>
<p>
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.
</p>
</div>
<section className="panel agent-templates-panel">
<div className="panel-heading">
<div>
<h2>Available templates</h2>
<p>Subscribe to enable API access and console testing.</p>
</div>
<Code2 size={19} />
</div>
{templates.length === 0 ? (
<div className="otp-empty">
<strong>No agent templates yet</strong>
<span>Templates will appear here as Urmi adds them to the platform catalog.</span>
</div>
) : (
<div className="agent-template-table">
<div className="agent-template-table-head">
<span>Template</span>
<span>Preview</span>
<span>Parameters</span>
<span>Status</span>
<span />
</div>
{templates.map((template) => {
const status = testStatus[template.slug]
const canTest = template.subscribed && (status?.allowed ?? true)
return (
<div className="agent-template-row" key={template.id}>
<div>
<strong>{template.name}</strong>
<small><code>{template.slug}</code> · Meta: {template.meta_template_name}</small>
<small>{template.description}</small>
</div>
<div className="agent-template-preview">
{template.header_text && <strong>{template.header_text}</strong>}
<p>{template.body_preview}</p>
{template.footer_text && <small>{template.footer_text}</small>}
{template.button_label && <span className="agent-template-button-chip">{template.button_label}</span>}
</div>
<div className="agent-template-params">
{template.parameters.map((param) => (
<div key={param.name}>
<code>{param.name}</code>
<span>{param.description}</span>
</div>
))}
</div>
<div>
<span className={`doc-status ${template.subscribed ? 'approved' : 'pending'}`}>
{template.subscribed ? 'Subscribed' : 'Not subscribed'}
</span>
{template.subscribed && status && !status.allowed && status.retry_after && (
<small>Test again after {new Date(status.retry_after).toLocaleString()}</small>
)}
</div>
<div className="agent-template-actions">
<button
className={template.subscribed ? 'secondary-button' : 'primary-button'}
onClick={() => toggleSubscribe(template)}
>
{template.subscribed ? 'Unsubscribe' : 'Subscribe'}
</button>
{template.subscribed && (
<>
<button
className="secondary-button"
disabled={!canTest}
onClick={() => setTestTemplate(template)}
>
<Play size={14} /> Test API
</button>
<button
className="secondary-button"
onClick={() => navigate(`/console/agent/urmiai/templates/${template.slug}/docs`)}
>
View docs <ArrowRight size={14} />
</button>
</>
)}
</div>
</div>
)
})}
</div>
)}
{(error || actionError) && <p className="auth-error">{error || actionError}</p>}
</section>
<section className="panel agent-templates-help">
<div className="panel-heading">
<div>
<h2>How it works</h2>
<p>Urmi acts as your WhatsApp tech provider middle layer.</p>
</div>
</div>
<ol className="relay-steps">
<li>Subscribe to a template in this console.</li>
<li>Create an API key under Develop → API Keys.</li>
<li>Call <code>POST /api/v1/agent/messages/send</code> with your Urmi API key.</li>
<li>Urmi validates your subscription and relays the message to Meta WhatsApp Cloud API.</li>
</ol>
<Link to="/console/api-explorer" className="secondary-button">Open API Explorer</Link>
</section>
{testTemplate && (
<TestModal
template={testTemplate}
onClose={() => setTestTemplate(null)}
onSent={() => load().catch(() => undefined)}
/>
)}
</div>
)
}

View file

@ -2,10 +2,13 @@ import { CheckCircle2, Copy, MessageSquareText, Phone, Play, Radio, ShieldCheck
import { useEffect, useMemo, useState, type ReactNode } from 'react' import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
agentMessagesApi,
integrationsApi, integrationsApi,
messagesApi, messagesApi,
numbersApi, numbersApi,
voiceApi, voiceApi,
type AgentMessageApiDoc,
type AgentMessageSendResponse,
type IntegrationsDocs, type IntegrationsDocs,
type MessageApiDoc, type MessageApiDoc,
type PhoneNumber, type PhoneNumber,
@ -116,6 +119,7 @@ function normalizeIntegrationDocs(raw: Partial<IntegrationsDocs> | null): Integr
export function ApiExplorerPage() { export function ApiExplorerPage() {
const [smsDoc, setSmsDoc] = useState<MessageApiDoc | null>(null) const [smsDoc, setSmsDoc] = useState<MessageApiDoc | null>(null)
const [agentDoc, setAgentDoc] = useState<AgentMessageApiDoc | null>(null)
const [voiceDoc, setVoiceDoc] = useState<VoiceApiDocs | null>(null) const [voiceDoc, setVoiceDoc] = useState<VoiceApiDocs | null>(null)
const [integrationDocs, setIntegrationDocs] = useState<IntegrationsDocs | null>(null) const [integrationDocs, setIntegrationDocs] = useState<IntegrationsDocs | null>(null)
const [numbers, setNumbers] = useState<PhoneNumber[]>([]) const [numbers, setNumbers] = useState<PhoneNumber[]>([])
@ -130,25 +134,38 @@ export function ApiExplorerPage() {
const [smsError, setSmsError] = useState('') const [smsError, setSmsError] = useState('')
const [smsRunning, setSmsRunning] = useState(false) 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<AgentMessageSendResponse | null>(null)
const [agentError, setAgentError] = useState('')
const [agentRunning, setAgentRunning] = useState(false)
const [fromDid, setFromDid] = useState('') const [fromDid, setFromDid] = useState('')
const [callTo, setCallTo] = useState('') const [callTo, setCallTo] = useState('')
const [voiceResponse, setVoiceResponse] = useState<VoiceCall | null>(null) const [voiceResponse, setVoiceResponse] = useState<VoiceCall | null>(null)
const [voiceError, setVoiceError] = useState('') const [voiceError, setVoiceError] = useState('')
const [voiceRunning, setVoiceRunning] = useState(false) 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('') const [loadError, setLoadError] = useState('')
useEffect(() => { useEffect(() => {
Promise.allSettled([ Promise.allSettled([
messagesApi.docs(), messagesApi.docs(),
agentMessagesApi.docs(),
voiceApi.docs(), voiceApi.docs(),
integrationsApi.docs(), integrationsApi.docs(),
numbersApi.mine(), numbersApi.mine(),
]) ])
.then((results) => { .then((results) => {
const [smsResult, voiceResult, integrationResult, numbersResult] = results const [smsResult, agentResult, voiceResult, integrationResult, numbersResult] = results
if (smsResult.status === 'fulfilled') setSmsDoc(smsResult.value) 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 (voiceResult.status === 'fulfilled') setVoiceDoc(normalizeVoiceDocs(voiceResult.value))
if (integrationResult.status === 'fulfilled') { if (integrationResult.status === 'fulfilled') {
setIntegrationDocs(normalizeIntegrationDocs(integrationResult.value)) setIntegrationDocs(normalizeIntegrationDocs(integrationResult.value))
@ -183,6 +200,22 @@ export function ApiExplorerPage() {
].join('\n') ].join('\n')
}, [apiKey, message, senderId, smsDoc, smsTo, templateId]) }, [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_<your_api_key>'}' \\`,
` -H 'Content-Type: application/json' \\`,
` -d '${body.replace(/'/g, `'\\''`)}'`,
].join('\n')
}, [agentCustomerName, agentDoc, agentReportId, agentTemplate, agentTo, apiKey])
const voiceCurl = useMemo(() => { const voiceCurl = useMemo(() => {
if (!voiceDoc) return '' if (!voiceDoc) return ''
const body = JSON.stringify({ 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() { async function runVoiceCall() {
setVoiceError('') setVoiceError('')
setVoiceResponse(null) setVoiceResponse(null)
@ -235,8 +287,9 @@ export function ApiExplorerPage() {
} }
} }
async function copyCurl(kind: 'sms' | 'voice') { async function copyCurl(kind: 'sms' | 'voice' | 'agent') {
await navigator.clipboard.writeText(kind === 'sms' ? smsCurl : voiceCurl) const text = kind === 'sms' ? smsCurl : kind === 'agent' ? agentCurl : voiceCurl
await navigator.clipboard.writeText(text)
setCopied(kind) setCopied(kind)
window.setTimeout(() => setCopied(null), 2000) window.setTimeout(() => setCopied(null), 2000)
} }
@ -248,7 +301,7 @@ export function ApiExplorerPage() {
</div> </div>
) )
} }
if (!smsDoc && !voiceDoc && !integrationDocs) { if (!smsDoc && !agentDoc && !voiceDoc && !integrationDocs) {
return ( return (
<div className="api-explorer-page"> <div className="api-explorer-page">
<div className="account-heading"> <div className="account-heading">
@ -345,6 +398,83 @@ export function ApiExplorerPage() {
</ExplorerGroup> </ExplorerGroup>
)} )}
{agentDoc && (
<ExplorerGroup title="Agent messages">
<section className="panel explorer-endpoint-card">
<div className="panel-heading">
<div>
<h3>{agentDoc.title}</h3>
<p>{agentDoc.summary}</p>
</div>
<span className="explorer-method">{agentDoc.method}</span>
</div>
<FieldRow label="Endpoint" value={`${API_BASE_URL}${agentDoc.path}`} />
<FieldRow label="Auth" value={`${agentDoc.authentication.header}: ${agentDoc.authentication.format}`} />
<div className="explorer-section">
<h4>Request body</h4>
<div className="explorer-request-grid">
{Object.entries(agentDoc.request_body).map(([key, field]) => (
<div className="explorer-input" key={key}>
<label>
<strong>{key}</strong>
<span>{field.description}</span>
<input
value={
key === 'template' ? agentTemplate
: key === 'to' ? agentTo
: key === 'customer_name' ? agentCustomerName
: agentReportId
}
onChange={(e) => {
if (key === 'template') setAgentTemplate(e.target.value)
if (key === 'to') setAgentTo(e.target.value)
if (key === 'customer_name') setAgentCustomerName(e.target.value)
if (key === 'report_id') setAgentReportId(e.target.value)
}}
placeholder={field.example}
/>
</label>
</div>
))}
</div>
</div>
<div className="explorer-section">
<h4>API key</h4>
<label className="explorer-input">
<strong>Bearer token</strong>
<span>Subscribe to the template in Agent → UrmiAI before testing.</span>
<input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="urmi_live_..." />
</label>
</div>
<div className="explorer-actions">
<button className="primary-button" onClick={runAgentMessage} disabled={agentRunning || !apiKey.trim()}>
<Play size={15} /> {agentRunning ? 'Sending…' : 'Try request'}
</button>
<button className="secondary-button" onClick={() => copyCurl('agent')}>
<Copy size={15} /> {copied === 'agent' ? 'Copied cURL' : 'Copy cURL'}
</button>
</div>
{agentError && <p className="auth-error">{agentError}</p>}
{agentResponse && (
<div className="explorer-response success">
<strong><CheckCircle2 size={15} /> 200 OK</strong>
<pre>{JSON.stringify(agentResponse, null, 2)}</pre>
</div>
)}
<div className="explorer-section">
<h4>Example cURL</h4>
<pre className="explorer-code">{agentCurl}</pre>
</div>
</section>
</ExplorerGroup>
)}
{voiceDoc && integrationDocs && ( {voiceDoc && integrationDocs && (
<ExplorerGroup title="Numbers"> <ExplorerGroup title="Numbers">
<RelayCallout principle={voiceDoc.relay.principle} latency={voiceDoc.relay.latency} /> <RelayCallout principle={voiceDoc.relay.principle} latency={voiceDoc.relay.latency} />

View file

@ -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 { 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 strong { color: #46546a; font-size: 12px; }
.otp-empty span { font-size: 10px; } .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 { 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, .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; } .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; } .api-key-row { grid-template-columns: 1fr; }
.otp-map-branches { grid-template-columns: repeat(4, 1fr); } .otp-map-branches { grid-template-columns: repeat(4, 1fr); }
.otp-map-branches::before, .otp-map-branches > div::before { display: none; } .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-table-head { display: none; }
.otp-project-row { grid-template-columns: 1fr; align-items: start; } .otp-project-row { grid-template-columns: 1fr; align-items: start; }
.templates-table-head { display: none; } .templates-table-head { display: none; }

View file

@ -420,6 +420,126 @@ export type AdminUrmiOtpProject = UrmiOtpProject & {
user: { id: string; name: string; email: string } 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<string, string>
example: Record<string, string>
response_example: Record<string, unknown>
}
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<string, {
type: string
required: boolean
description: string
example: string
}>
responses: Record<string, { description: string; example?: Record<string, unknown> }>
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<AgentMessageTemplate[]>('/api/v1/agent-templates'),
subscribe: (slug: string) =>
jsonRequest<AgentMessageTemplate>(`/api/v1/agent-templates/${slug}/subscribe`, { method: 'POST' }),
unsubscribe: (slug: string) =>
jsonRequest<AgentMessageTemplate>(`/api/v1/agent-templates/${slug}/subscribe`, { method: 'DELETE' }),
documentation: (slug: string) =>
jsonRequest<AgentTemplateDocumentation>(`/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<AgentMessageSendResponse>(`/api/v1/agent-templates/${slug}/test`, {
method: 'POST',
body: JSON.stringify(payload),
}),
}
export const agentMessagesApi = {
docs: () => jsonRequest<AgentMessageApiDoc>('/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<AgentMessageSendResponse>
}),
}
export const urmiOtpApi = { export const urmiOtpApi = {
listProjects: () => jsonRequest<UrmiOtpProject[]>('/api/v1/urmiotp/projects'), listProjects: () => jsonRequest<UrmiOtpProject[]>('/api/v1/urmiotp/projects'),
createProject: (payload: { createProject: (payload: {