first commit

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
chelsea
2026-07-04 04:39:53 +00:00
commit db5bde0a70
13 changed files with 1095 additions and 0 deletions

203
sms/app.py Normal file
View File

@@ -0,0 +1,203 @@
"""FastAPI façade exposing the voip.ms SMS/MMS operations over HTTP.
Run:
export VOIPMS_API_USERNAME=... VOIPMS_API_PASSWORD=...
uvicorn sms.app:app --reload
Then open http://127.0.0.1:8000/docs
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import FastAPI, Query, Request, status
from fastapi.responses import JSONResponse, PlainTextResponse
from .client import VoipMsSMSClient
from .config import Settings
from .exceptions import VoipMsApiError, VoipMsAuthError, VoipMsError, VoipMsRateLimitError
from .guard import DailySendCounter
from .models import (
DeleteResult,
MediaResult,
MmsRecord,
SendMmsRequest,
SendResult,
SendSmsRequest,
SmsRecord,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = Settings() # validates env creds; raises clearly if missing
app.state.settings = settings
app.state.client = VoipMsSMSClient(settings)
app.state.guard = DailySendCounter(limit=settings.daily_limit)
try:
yield
finally:
await app.state.client.aclose()
app = FastAPI(
title="voip.ms SMS/MMS API façade",
description="Thin typed proxy over the voip.ms SMS/MMS REST API.",
version="0.1.0",
lifespan=lifespan,
)
def _client(request: Request) -> VoipMsSMSClient:
return request.app.state.client
def _guard(request: Request) -> DailySendCounter:
return request.app.state.guard
def _map_error(exc: VoipMsError) -> JSONResponse:
if isinstance(exc, VoipMsRateLimitError):
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={"detail": str(exc), "limit": exc.limit, "used_today": exc.used_today},
)
if isinstance(exc, VoipMsAuthError):
# 500 — don't leak auth context to callers; log it server-side.
logging.getLogger("voipms.sms.app").error("auth error: %s", exc)
return JSONResponse(status_code=500, content={"detail": "upstream authentication failure"})
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content={"detail": str(exc)},
)
# --- outbound (guarded) ----------------------------------------------------
@app.post("/sms/send", response_model=SendResult)
async def send_sms(body: SendSmsRequest, request: Request) -> SendResult:
guard = _guard(request)
client = _client(request)
try:
guard.check_and_increment()
result = await client.send_sms(did=body.did, dst=body.dst, message=body.message)
except VoipMsError as exc:
return _map_error(exc)
guard.log_send(kind="sms", did=body.did, dst=body.dst, chars=len(body.message))
return result
@app.post("/mms/send", response_model=SendResult)
async def send_mms(body: SendMmsRequest, request: Request) -> SendResult:
guard = _guard(request)
client = _client(request)
try:
guard.check_and_increment()
result = await client.send_mms(
did=body.did,
dst=body.dst,
message=body.message,
media1=body.media1,
media2=body.media2,
media3=body.media3,
)
except VoipMsError as exc:
return _map_error(exc)
guard.log_send(kind="mms", did=body.did, dst=body.dst, chars=len(body.message))
return result
# --- history / retrieval ---------------------------------------------------
@app.get("/sms", response_model=list[SmsRecord])
async def list_sms(
request: Request,
sms: Annotated[int | None, Query(description="Specific SMS id")] = None,
date_from: Annotated[str | None, Query(alias="from", description="YYYY-MM-DD")] = None,
date_to: Annotated[str | None, Query(alias="to", description="YYYY-MM-DD")] = None,
type: Annotated[int | None, Query(description="1=received, 0=sent")] = None,
did: Annotated[str | None, Query()] = None,
contact: Annotated[str | None, Query()] = None,
limit: Annotated[int | None, Query(ge=1)] = None,
timezone: Annotated[int | None, Query(ge=-12, le=13)] = None,
all_messages: Annotated[int | None, Query(description="1=SMS+MMS, 0=SMS only")] = None,
) -> list[SmsRecord]:
client = _client(request)
try:
return await client.get_sms(
sms=sms, from_=date_from, to=date_to, type=type, did=did,
contact=contact, limit=limit, timezone=timezone, all_messages=all_messages,
)
except VoipMsError as exc:
return _map_error(exc)
@app.get("/mms", response_model=list[MmsRecord])
async def list_mms(
request: Request,
id: Annotated[int | None, Query(description="Specific MMS id")] = None,
date_from: Annotated[str | None, Query(alias="from")] = None,
date_to: Annotated[str | None, Query(alias="to")] = None,
type: Annotated[int | None, Query()] = None,
did: Annotated[str | None, Query()] = None,
contact: Annotated[str | None, Query()] = None,
limit: Annotated[int | None, Query(ge=1)] = None,
timezone: Annotated[int | None, Query(ge=-12, le=13)] = None,
all_messages: Annotated[int | None, Query()] = None,
) -> list[MmsRecord]:
client = _client(request)
try:
return await client.get_mms(
id=id, from_=date_from, to=date_to, type=type, did=did,
contact=contact, limit=limit, timezone=timezone, all_messages=all_messages,
)
except VoipMsError as exc:
return _map_error(exc)
@app.get("/mms/{id}/media", response_model=MediaResult)
async def get_mms_media(
request: Request,
id: int,
media_as_array: Annotated[bool, Query()] = False,
) -> MediaResult:
client = _client(request)
try:
return await client.get_media_mms(id=id, media_as_array=media_as_array)
except VoipMsError as exc:
return _map_error(exc)
# --- delete ----------------------------------------------------------------
@app.delete("/sms/{id}", response_model=DeleteResult)
async def delete_sms(request: Request, id: int) -> DeleteResult:
client = _client(request)
try:
return await client.delete_sms(id=id)
except VoipMsError as exc:
return _map_error(exc)
@app.delete("/mms/{id}", response_model=DeleteResult)
async def delete_mms(request: Request, id: int) -> DeleteResult:
client = _client(request)
try:
return await client.delete_mms(id=id)
except VoipMsError as exc:
return _map_error(exc)
# --- liveness --------------------------------------------------------------
@app.get("/health", response_class=PlainTextResponse)
async def health() -> str:
return "ok"