WA template
This commit is contained in:
parent
7a2e21ba61
commit
d272ae0bd3
12 changed files with 1359 additions and 5 deletions
277
backend/app/agent_message_service.py
Normal file
277
backend/app/agent_message_service.py
Normal 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.",
|
||||||
|
],
|
||||||
|
}
|
||||||
63
backend/app/agent_templates_seed.py
Normal file
63
backend/app/agent_templates_seed.py
Normal 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()
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)."""
|
||||||
|
|
||||||
|
|
|
||||||
106
backend/app/routes_agent_messages.py
Normal file
106
backend/app/routes_agent_messages.py
Normal 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")
|
||||||
144
backend/app/routes_agent_templates.py
Normal file
144
backend/app/routes_agent_templates.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
91
frontend/src/features/agent/AgentTemplateDocsPage.tsx
Normal file
91
frontend/src/features/agent/AgentTemplateDocsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
248
frontend/src/features/agent/AgentTemplatesPage.tsx
Normal file
248
frontend/src/features/agent/AgentTemplatesPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue