first commit

This commit is contained in:
debu-2 2026-10-02 11:22:40 +05:30
commit efa5080897
127 changed files with 18610 additions and 0 deletions

View file

@ -0,0 +1,363 @@
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()