363 lines
14 KiB
Python
363 lines
14 KiB
Python
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session, joinedload
|
|
|
|
from .database import get_db
|
|
from .endpoint_security import endpoint_signing_secret, validate_endpoint_url
|
|
from .models import (
|
|
PhoneNumber,
|
|
SmsDeliveryWebhook,
|
|
UserAccount,
|
|
VoiceSocketEndpoint,
|
|
)
|
|
from .sms_webhooks import deliver_test_report
|
|
from .voice_auth import current_voice_user
|
|
|
|
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
|
|
|
|
|
class EndpointCreate(BaseModel):
|
|
name: str = Field(min_length=2, max_length=120)
|
|
phone_number_id: UUID
|
|
url: str = Field(min_length=8, max_length=500)
|
|
description: str | None = Field(default=None, max_length=500)
|
|
enabled: bool = True
|
|
|
|
|
|
class EndpointUpdate(BaseModel):
|
|
name: str | None = Field(default=None, min_length=2, max_length=120)
|
|
phone_number_id: UUID | None = None
|
|
url: str | None = Field(default=None, min_length=8, max_length=500)
|
|
description: str | None = Field(default=None, max_length=500)
|
|
enabled: bool | None = None
|
|
|
|
|
|
def _owned_number(db: Session, user: UserAccount, number_id: UUID) -> PhoneNumber:
|
|
number = db.scalar(
|
|
select(PhoneNumber).where(
|
|
PhoneNumber.id == number_id,
|
|
PhoneNumber.user_id == user.id,
|
|
PhoneNumber.status == "assigned",
|
|
)
|
|
)
|
|
if not number:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Phone number not found")
|
|
return number
|
|
|
|
|
|
def _clean_name(value: str) -> str:
|
|
name = " ".join(value.split())
|
|
if len(name) < 2:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Name must contain at least 2 characters",
|
|
)
|
|
return name
|
|
|
|
|
|
def _commit(db: Session, duplicate_detail: str) -> None:
|
|
try:
|
|
db.commit()
|
|
except IntegrityError as exc:
|
|
db.rollback()
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=duplicate_detail) from exc
|
|
|
|
|
|
def _serialize_socket(endpoint: VoiceSocketEndpoint, *, include_secret: bool = False) -> dict:
|
|
payload = {
|
|
"id": str(endpoint.id),
|
|
"name": endpoint.name,
|
|
"phone_number_id": str(endpoint.phone_number_id),
|
|
"phone_number": endpoint.phone_number.phone_number,
|
|
"url": endpoint.url,
|
|
"enabled": endpoint.enabled,
|
|
"created_at": endpoint.created_at,
|
|
"updated_at": endpoint.updated_at,
|
|
}
|
|
if include_secret:
|
|
payload["signing_secret"] = endpoint_signing_secret("socket", endpoint.id)
|
|
return payload
|
|
|
|
|
|
def _serialize_webhook(endpoint: SmsDeliveryWebhook, *, include_secret: bool = False) -> dict:
|
|
payload = {
|
|
"id": str(endpoint.id),
|
|
"name": endpoint.name,
|
|
"phone_number_id": str(endpoint.phone_number_id),
|
|
"phone_number": endpoint.phone_number.phone_number,
|
|
"url": endpoint.url,
|
|
"description": endpoint.description,
|
|
"event": "message.delivery.updated",
|
|
"enabled": endpoint.enabled,
|
|
"created_at": endpoint.created_at,
|
|
"updated_at": endpoint.updated_at,
|
|
}
|
|
if include_secret:
|
|
payload["signing_secret"] = endpoint_signing_secret("webhook", endpoint.id)
|
|
return payload
|
|
|
|
|
|
@router.get("/docs")
|
|
def integration_docs() -> dict:
|
|
return {
|
|
"title": "Webhooks & Sockets",
|
|
"summary": "Connect each Urmi number to your application.",
|
|
"relay": {
|
|
"principle": "Urmi forwards raw WebSocket and HTTP payloads without decoding or modifying them. We authenticate requests and route traffic — all processing happens on your infrastructure.",
|
|
"latency": "Voice media frames are relayed as they arrive to keep latency as low as possible.",
|
|
},
|
|
"socket": {
|
|
"title": "Voice sockets",
|
|
"description": "Link a secure WebSocket endpoint to a number. When a call connects, Urmi opens your endpoint and relays frames in both directions with no audio processing on our side.",
|
|
"events": ["connected", "start", "media", "end"],
|
|
"authentication": {
|
|
"headers": ["X-Urmi-Endpoint-Id", "X-Urmi-Timestamp", "X-Urmi-Signature"],
|
|
"signature": "HMAC-SHA256 of '<endpoint_id>.<timestamp>' using the signing secret.",
|
|
"replay_protection": "Reject timestamps older than five minutes.",
|
|
},
|
|
},
|
|
"webhook": {
|
|
"title": "SMS delivery webhooks",
|
|
"description": "Link a named HTTPS endpoint to a number to receive SMS delivery status updates.",
|
|
"event": "message.delivery.updated",
|
|
"statuses": ["queued", "sent", "delivered", "undelivered", "failed"],
|
|
"authentication": {
|
|
"headers": ["X-Urmi-Event", "X-Urmi-Endpoint-Id", "X-Urmi-Signature"],
|
|
"signature": "HMAC-SHA256 of the raw request body using the signing secret.",
|
|
},
|
|
"example": {
|
|
"id": "evt_01J8EXAMPLE",
|
|
"event": "message.delivery.updated",
|
|
"created_at": "2026-09-04T09:30:00Z",
|
|
"data": {
|
|
"message_id": "msg_01J8EXAMPLE",
|
|
"to": "+919999999999",
|
|
"from": "+917946350706",
|
|
"status": "delivered",
|
|
"updated_at": "2026-09-04T09:29:58Z",
|
|
},
|
|
},
|
|
},
|
|
"api_authentication": {
|
|
"header": "Authorization",
|
|
"format": "Bearer urmi_live_<your_api_key>",
|
|
"note": "Use API keys only from your server. Never expose them in browser code.",
|
|
},
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/v1/integrations/sockets", "description": "List voice sockets"},
|
|
{"method": "POST", "path": "/api/v1/integrations/sockets", "description": "Create and link a voice socket"},
|
|
{"method": "PATCH", "path": "/api/v1/integrations/sockets/{id}", "description": "Update or pause a voice socket"},
|
|
{"method": "DELETE", "path": "/api/v1/integrations/sockets/{id}", "description": "Delete a voice socket"},
|
|
{"method": "GET", "path": "/api/v1/integrations/webhooks", "description": "List SMS delivery webhooks"},
|
|
{"method": "POST", "path": "/api/v1/integrations/webhooks", "description": "Create and link a delivery webhook"},
|
|
{"method": "PATCH", "path": "/api/v1/integrations/webhooks/{id}", "description": "Update or pause a delivery webhook"},
|
|
{"method": "DELETE", "path": "/api/v1/integrations/webhooks/{id}", "description": "Delete a delivery webhook"},
|
|
],
|
|
}
|
|
|
|
|
|
@router.get("/sockets")
|
|
def list_sockets(
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_voice_user),
|
|
) -> list[dict]:
|
|
endpoints = db.scalars(
|
|
select(VoiceSocketEndpoint)
|
|
.options(joinedload(VoiceSocketEndpoint.phone_number))
|
|
.where(VoiceSocketEndpoint.user_id == user.id)
|
|
.order_by(VoiceSocketEndpoint.created_at.desc())
|
|
).all()
|
|
return [_serialize_socket(endpoint) for endpoint in endpoints]
|
|
|
|
|
|
@router.post("/sockets", status_code=status.HTTP_201_CREATED)
|
|
def create_socket(
|
|
payload: EndpointCreate,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_voice_user),
|
|
) -> dict:
|
|
number = _owned_number(db, user, payload.phone_number_id)
|
|
try:
|
|
url = validate_endpoint_url(payload.url, websocket=True)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
|
endpoint = VoiceSocketEndpoint(
|
|
user_id=user.id,
|
|
phone_number_id=number.id,
|
|
name=_clean_name(payload.name),
|
|
url=url,
|
|
enabled=payload.enabled,
|
|
)
|
|
db.add(endpoint)
|
|
_commit(db, "This name or phone number already has a voice socket")
|
|
db.refresh(endpoint)
|
|
endpoint.phone_number = number
|
|
return _serialize_socket(endpoint, include_secret=True)
|
|
|
|
|
|
@router.patch("/sockets/{endpoint_id}")
|
|
def update_socket(
|
|
endpoint_id: UUID,
|
|
payload: EndpointUpdate,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_voice_user),
|
|
) -> dict:
|
|
endpoint = db.scalar(
|
|
select(VoiceSocketEndpoint)
|
|
.options(joinedload(VoiceSocketEndpoint.phone_number))
|
|
.where(VoiceSocketEndpoint.id == endpoint_id, VoiceSocketEndpoint.user_id == user.id)
|
|
)
|
|
if not endpoint:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice socket not found")
|
|
if payload.name is not None:
|
|
endpoint.name = _clean_name(payload.name)
|
|
if payload.phone_number_id is not None:
|
|
endpoint.phone_number = _owned_number(db, user, payload.phone_number_id)
|
|
endpoint.phone_number_id = payload.phone_number_id
|
|
if payload.url is not None:
|
|
try:
|
|
endpoint.url = validate_endpoint_url(payload.url, websocket=True)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
|
if payload.enabled is not None:
|
|
endpoint.enabled = payload.enabled
|
|
_commit(db, "This name or phone number already has a voice socket")
|
|
db.refresh(endpoint)
|
|
return _serialize_socket(endpoint)
|
|
|
|
|
|
@router.delete("/sockets/{endpoint_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_socket(
|
|
endpoint_id: UUID,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_voice_user),
|
|
) -> None:
|
|
endpoint = db.scalar(
|
|
select(VoiceSocketEndpoint).where(
|
|
VoiceSocketEndpoint.id == endpoint_id,
|
|
VoiceSocketEndpoint.user_id == user.id,
|
|
)
|
|
)
|
|
if not endpoint:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice socket not found")
|
|
db.delete(endpoint)
|
|
db.commit()
|
|
|
|
|
|
@router.get("/webhooks")
|
|
def list_webhooks(
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_voice_user),
|
|
) -> list[dict]:
|
|
endpoints = db.scalars(
|
|
select(SmsDeliveryWebhook)
|
|
.options(joinedload(SmsDeliveryWebhook.phone_number))
|
|
.where(SmsDeliveryWebhook.user_id == user.id)
|
|
.order_by(SmsDeliveryWebhook.created_at.desc())
|
|
).all()
|
|
return [_serialize_webhook(endpoint) for endpoint in endpoints]
|
|
|
|
|
|
@router.post("/webhooks", status_code=status.HTTP_201_CREATED)
|
|
def create_webhook(
|
|
payload: EndpointCreate,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_voice_user),
|
|
) -> dict:
|
|
number = _owned_number(db, user, payload.phone_number_id)
|
|
try:
|
|
url = validate_endpoint_url(payload.url, websocket=False)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
|
endpoint = SmsDeliveryWebhook(
|
|
user_id=user.id,
|
|
phone_number_id=number.id,
|
|
name=_clean_name(payload.name),
|
|
url=url,
|
|
description=payload.description.strip() if payload.description else None,
|
|
enabled=payload.enabled,
|
|
)
|
|
db.add(endpoint)
|
|
_commit(db, "A webhook with this name already exists")
|
|
db.refresh(endpoint)
|
|
endpoint.phone_number = number
|
|
return _serialize_webhook(endpoint, include_secret=True)
|
|
|
|
|
|
@router.patch("/webhooks/{endpoint_id}")
|
|
def update_webhook(
|
|
endpoint_id: UUID,
|
|
payload: EndpointUpdate,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_voice_user),
|
|
) -> dict:
|
|
endpoint = db.scalar(
|
|
select(SmsDeliveryWebhook)
|
|
.options(joinedload(SmsDeliveryWebhook.phone_number))
|
|
.where(SmsDeliveryWebhook.id == endpoint_id, SmsDeliveryWebhook.user_id == user.id)
|
|
)
|
|
if not endpoint:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
|
|
if payload.name is not None:
|
|
endpoint.name = _clean_name(payload.name)
|
|
if payload.phone_number_id is not None:
|
|
endpoint.phone_number = _owned_number(db, user, payload.phone_number_id)
|
|
endpoint.phone_number_id = payload.phone_number_id
|
|
if payload.url is not None:
|
|
try:
|
|
endpoint.url = validate_endpoint_url(payload.url, websocket=False)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
|
if "description" in payload.model_fields_set:
|
|
endpoint.description = payload.description.strip() if payload.description else None
|
|
if payload.enabled is not None:
|
|
endpoint.enabled = payload.enabled
|
|
_commit(db, "A webhook with this name already exists")
|
|
db.refresh(endpoint)
|
|
return _serialize_webhook(endpoint)
|
|
|
|
|
|
@router.post("/webhooks/{endpoint_id}/test")
|
|
async def test_webhook(
|
|
endpoint_id: UUID,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_voice_user),
|
|
) -> dict:
|
|
endpoint = db.scalar(
|
|
select(SmsDeliveryWebhook).where(
|
|
SmsDeliveryWebhook.id == endpoint_id,
|
|
SmsDeliveryWebhook.user_id == user.id,
|
|
)
|
|
)
|
|
if not endpoint:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
|
|
try:
|
|
response_code = await deliver_test_report(endpoint)
|
|
except RuntimeError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=str(exc),
|
|
) from exc
|
|
return {"delivered": True, "response_code": response_code}
|
|
|
|
|
|
@router.delete("/webhooks/{endpoint_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_webhook(
|
|
endpoint_id: UUID,
|
|
db: Session = Depends(get_db),
|
|
user: UserAccount = Depends(current_voice_user),
|
|
) -> None:
|
|
endpoint = db.scalar(
|
|
select(SmsDeliveryWebhook).where(
|
|
SmsDeliveryWebhook.id == endpoint_id,
|
|
SmsDeliveryWebhook.user_id == user.id,
|
|
)
|
|
)
|
|
if not endpoint:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
|
|
db.delete(endpoint)
|
|
db.commit()
|