91
sms/README.md
Normal file
91
sms/README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# sms — voip.ms SMS/MMS API façade
|
||||
|
||||
A small FastAPI service that wraps the [voip.ms](https://voip.ms) SMS/MMS REST
|
||||
API (`https://voip.ms/api/v1/rest.php`) behind typed Python + REST endpoints,
|
||||
with a per-day outbound send guard.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `config.py` | `Settings` (env-loaded creds, dialing mode, daily limit, timeout) |
|
||||
| `exceptions.py` | `VoipMsError` hierarchy (`Auth`, `Api`, `RateLimit`) |
|
||||
| `models.py` | Pydantic request/response models |
|
||||
| `client.py` | `VoipMsSMSClient` — async `httpx` wrappers for the 7 methods |
|
||||
| `guard.py` | `DailySendCounter` — UTC-day counter + send logging |
|
||||
| `app.py` | FastAPI app exposing the operations as REST endpoints |
|
||||
| `requirements.txt` | Dependencies |
|
||||
|
||||
## voip.ms prerequisites
|
||||
|
||||
1. Enable the REST API and set an API password in the portal:
|
||||
**Main Menu → SOAP / REST API → API Security**.
|
||||
2. **Whitelist this machine's public IP** in the same API Security page.
|
||||
3. The DID you send from must have **SMS enabled** (Manage DIDs → Edit DID).
|
||||
4. Sending is capped at **100 SMS/MMS per day** via the API. Pricing:
|
||||
$0.0075/SMS, $0.02/MMS, each direction.
|
||||
|
||||
## Configure
|
||||
|
||||
```sh
|
||||
export VOIPMS_API_USERNAME=...
|
||||
export VOIPMS_API_PASSWORD=...
|
||||
# optional overrides:
|
||||
# export VOIPMS_BASE_URL=https://voip.ms/api/v1/rest.php
|
||||
# export VOIPMS_DIALING_MODE=nanpa # or e164
|
||||
# export VOIPMS_DAILY_LIMIT=100
|
||||
# export VOIPMS_TIMEOUT=30
|
||||
```
|
||||
|
||||
(`Settings` also reads a `.env` file in the working directory.)
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
pip install -r sms/requirements.txt
|
||||
uvicorn sms.app:app --reload
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8000/docs` for the Swagger UI.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Upstream | Guarded |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/sms/send` | `sendSMS` | ✅ |
|
||||
| POST | `/mms/send` | `sendMMS` | ✅ |
|
||||
| GET | `/sms` | `getSMS` | — |
|
||||
| GET | `/mms` | `getMMS` | — |
|
||||
| GET | `/mms/{id}/media` | `getMediaMMS` | — |
|
||||
| DELETE | `/sms/{id}` | `deleteSMS` | — |
|
||||
| DELETE | `/mms/{id}` | `deleteMMS` | — |
|
||||
| GET | `/health` | — | — |
|
||||
|
||||
### Query params for `GET /sms` / `GET /mms`
|
||||
|
||||
`from`, `to` (YYYY-MM-DD), `type` (1=received / 0=sent), `did`, `contact`,
|
||||
`limit`, `timezone` (-12..13), `all_messages` (1=SMS+MMS combined, 0=single
|
||||
type; the message id must be 0 when set). For `/sms` the id query param is
|
||||
`sms`; for `/mms` it is `id`.
|
||||
|
||||
## Error mapping
|
||||
|
||||
| Condition | HTTP | Body |
|
||||
| --- | --- | --- |
|
||||
| Daily limit exceeded | 429 | `{detail, limit, used_today}` |
|
||||
| voip.ms returns non-success status | 502 | upstream message |
|
||||
| Auth / IP-whitelist failure | 500 | `upstream authentication failure` (details logged server-side) |
|
||||
|
||||
## Notes & limitations
|
||||
|
||||
- The daily counter is **per-process**. For multiple instances, swap
|
||||
`DailySendCounter` for a shared store (Redis, etc.).
|
||||
- Use `https://voip.ms` (not `www.voip.ms`) — the `www` host 302-redirects
|
||||
and drops POST bodies, producing `missing_method` errors.
|
||||
- Dialing mode normalizes `did`/`dst` on send: `nanpa` → 10 digits,
|
||||
`e164` → `+1` + 10 digits.
|
||||
- No `markSMSRead` method exists in the voip.ms API; read state is not
|
||||
manageable via REST.
|
||||
- Inbound URL Callback receiver is **not** included here; it can be added
|
||||
as a separate route set that replies with the literal `ok` and persists
|
||||
`{TO}/{FROM}/{MESSAGE}/{ID}/{TIMESTAMP}/{MEDIA}`.
|
||||
25
sms/__init__.py
Normal file
25
sms/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""voip.ms SMS/MMS API façade.
|
||||
|
||||
Exposes the typed client and settings for direct import, plus the FastAPI
|
||||
app for serving the same operations over HTTP.
|
||||
"""
|
||||
|
||||
from .client import VoipMsSMSClient
|
||||
from .config import Settings
|
||||
from .exceptions import (
|
||||
VoipMsApiError,
|
||||
VoipMsAuthError,
|
||||
VoipMsError,
|
||||
VoipMsRateLimitError,
|
||||
)
|
||||
from .guard import DailySendCounter
|
||||
|
||||
__all__ = [
|
||||
"VoipMsSMSClient",
|
||||
"Settings",
|
||||
"DailySendCounter",
|
||||
"VoipMsError",
|
||||
"VoipMsAuthError",
|
||||
"VoipMsApiError",
|
||||
"VoipMsRateLimitError",
|
||||
]
|
||||
203
sms/app.py
Normal file
203
sms/app.py
Normal 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"
|
||||
243
sms/client.py
Normal file
243
sms/client.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""Async client for the voip.ms SMS/MMS REST API.
|
||||
|
||||
Wraps the seven documented methods against https://voip.ms/api/v1/rest.php:
|
||||
sendSMS, sendMMS, getSMS, getMMS, getMediaMMS, deleteSMS, deleteMMS
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import Settings
|
||||
from .exceptions import VoipMsApiError, VoipMsAuthError, VoipMsError
|
||||
from .models import DeleteResult, MediaResult, MmsRecord, SendResult, SmsRecord
|
||||
|
||||
_DIGITS = re.compile(r"\d+")
|
||||
|
||||
|
||||
def _digits(value: str) -> str:
|
||||
return "".join(_DIGITS.findall(value or ""))
|
||||
|
||||
|
||||
def normalize_number(value: str, mode: str) -> str:
|
||||
"""Render a US/Canada number in the configured dialing mode.
|
||||
|
||||
nanpa -> 10 digits (NPANXXXXXX)
|
||||
e164 -> +1 then 10 digits
|
||||
"""
|
||||
digits = _digits(value)
|
||||
# Tolerate an 11-digit number with leading 1.
|
||||
if len(digits) == 11 and digits.startswith("1"):
|
||||
digits = digits[1:]
|
||||
if len(digits) != 10:
|
||||
# Don't silently mangle unexpected shapes; pass through normalized digits.
|
||||
return digits
|
||||
if mode == "e164":
|
||||
return f"+1{digits}"
|
||||
return digits
|
||||
|
||||
|
||||
# voip.ms returns one of these `status` values (with a "There are no ... messages"
|
||||
# message) when a getSMS/getMMS query matches nothing. Treat as an empty list,
|
||||
# not an error.
|
||||
_EMPTY_STATUSES = {"no_sms", "no_mms"}
|
||||
|
||||
|
||||
def _is_empty(payload: dict[str, Any]) -> bool:
|
||||
status = str(payload.get("status", "")).lower()
|
||||
if status in _EMPTY_STATUSES:
|
||||
return True
|
||||
message = str(payload.get("message") or "").lower()
|
||||
return "there are no" in message and "message" in message
|
||||
|
||||
|
||||
_AUTH_HINTS = (
|
||||
"invalid api", "incorrect api", "api_username", "api_password",
|
||||
"ip", "authorized", "whitelist",
|
||||
"incorrect", "invalid_credentials", "username or password", "credentials",
|
||||
)
|
||||
|
||||
|
||||
class VoipMsSMSClient:
|
||||
"""Thin async wrapper over the voip.ms SMS/MMS REST API."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._http = httpx.AsyncClient(
|
||||
base_url=settings.base_url,
|
||||
timeout=settings.timeout,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._http.aclose()
|
||||
|
||||
# --- internals --------------------------------------------------------
|
||||
|
||||
async def _call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Perform one GET (or POST for base64 media) and return parsed JSON."""
|
||||
query: dict[str, Any] = {
|
||||
"api_username": self._settings.api_username,
|
||||
"api_password": self._settings.api_password,
|
||||
"method": method,
|
||||
}
|
||||
if params:
|
||||
query.update({k: v for k, v in params.items() if v is not None})
|
||||
|
||||
try:
|
||||
response = await self._http.get("", params=query)
|
||||
except httpx.HTTPError as exc:
|
||||
raise VoipMsError(f"transport error calling {method}: {exc}") from exc
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise VoipMsError(f"HTTP {response.status_code} from voip.ms calling {method}")
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise VoipMsError(f"non-JSON response from {method}: {response.text[:200]}") from exc
|
||||
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _check_status(payload: dict[str, Any], method: str) -> None:
|
||||
status = str(payload.get("status", "")).lower()
|
||||
if status == "success":
|
||||
return
|
||||
message = str(payload.get("message") or payload or "unknown error")
|
||||
# Match against both the message and the status string (e.g.
|
||||
# "invalid_credentials") so auth failures classify as VoipMsAuthError.
|
||||
haystack = f"{status} {message}".lower()
|
||||
if any(hint in haystack for hint in _AUTH_HINTS):
|
||||
raise VoipMsAuthError(f"{method}: {message}")
|
||||
raise VoipMsApiError(f"{method}: {message}", code=payload.get("code"))
|
||||
|
||||
# --- outbound ---------------------------------------------------------
|
||||
|
||||
async def send_sms(self, *, did: str, dst: str, message: str) -> SendResult:
|
||||
if len(message) > 160:
|
||||
raise VoipMsError("SMS message exceeds 160 characters")
|
||||
mode = self._settings.dialing_mode
|
||||
payload = await self._call("sendSMS", {
|
||||
"did": normalize_number(did, mode),
|
||||
"dst": normalize_number(dst, mode),
|
||||
"message": message,
|
||||
})
|
||||
self._check_status(payload, "sendSMS")
|
||||
return SendResult(id=int(payload["sms"]))
|
||||
|
||||
async def send_mms(
|
||||
self,
|
||||
*,
|
||||
did: str,
|
||||
dst: str,
|
||||
message: str,
|
||||
media1: str | None = None,
|
||||
media2: str | None = None,
|
||||
media3: str | None = None,
|
||||
) -> SendResult:
|
||||
if len(message) > 2048:
|
||||
raise VoipMsError("MMS message exceeds 2048 characters")
|
||||
mode = self._settings.dialing_mode
|
||||
payload = await self._call("sendMMS", {
|
||||
"did": normalize_number(did, mode),
|
||||
"dst": normalize_number(dst, mode),
|
||||
"message": message,
|
||||
"media1": media1,
|
||||
"media2": media2,
|
||||
"media3": media3,
|
||||
})
|
||||
self._check_status(payload, "sendMMS")
|
||||
# sendMMS returns the new id under the "sms" key per the docs examples.
|
||||
return SendResult(id=int(payload.get("sms", payload.get("mms", 0))))
|
||||
|
||||
# --- inbound / history ------------------------------------------------
|
||||
|
||||
async def get_sms(
|
||||
self,
|
||||
*,
|
||||
sms: int | None = None,
|
||||
from_: str | None = None,
|
||||
to: str | None = None,
|
||||
type: int | None = None,
|
||||
did: str | None = None,
|
||||
contact: str | None = None,
|
||||
limit: int | None = None,
|
||||
timezone: int | None = None,
|
||||
all_messages: int | None = None,
|
||||
) -> list[SmsRecord]:
|
||||
payload = await self._call("getSMS", {
|
||||
"sms": sms,
|
||||
"from": from_,
|
||||
"to": to,
|
||||
"type": type,
|
||||
"did": did,
|
||||
"contact": contact,
|
||||
"limit": limit,
|
||||
"timezone": timezone,
|
||||
"all_messages": all_messages,
|
||||
})
|
||||
if _is_empty(payload):
|
||||
return []
|
||||
self._check_status(payload, "getSMS")
|
||||
rows = payload.get("sms") or []
|
||||
return [SmsRecord.model_validate(row) for row in rows]
|
||||
|
||||
async def get_mms(
|
||||
self,
|
||||
*,
|
||||
id: int | None = None,
|
||||
from_: str | None = None,
|
||||
to: str | None = None,
|
||||
type: int | None = None,
|
||||
did: str | None = None,
|
||||
contact: str | None = None,
|
||||
limit: int | None = None,
|
||||
timezone: int | None = None,
|
||||
all_messages: int | None = None,
|
||||
) -> list[MmsRecord]:
|
||||
payload = await self._call("getMMS", {
|
||||
"mms": id,
|
||||
"from": from_,
|
||||
"to": to,
|
||||
"type": type,
|
||||
"did": did,
|
||||
"contact": contact,
|
||||
"limit": limit,
|
||||
"timezone": timezone,
|
||||
"all_messages": all_messages,
|
||||
})
|
||||
if _is_empty(payload):
|
||||
return []
|
||||
self._check_status(payload, "getMMS")
|
||||
rows = payload.get("mms") or []
|
||||
return [MmsRecord.model_validate(row) for row in rows]
|
||||
|
||||
async def get_media_mms(self, *, id: int, media_as_array: bool = False) -> MediaResult:
|
||||
payload = await self._call("getMediaMMS", {
|
||||
"id": id,
|
||||
"media_as_array": 1 if media_as_array else 0,
|
||||
})
|
||||
self._check_status(payload, "getMediaMMS")
|
||||
media = payload.get("media")
|
||||
if isinstance(media, dict):
|
||||
media_list = [v for v in media.values() if v]
|
||||
elif isinstance(media, list):
|
||||
media_list = [v for v in media if v]
|
||||
else:
|
||||
media_list = []
|
||||
return MediaResult(id=payload.get("id", id), date=payload.get("date"), media=media_list)
|
||||
|
||||
# --- delete -----------------------------------------------------------
|
||||
|
||||
async def delete_sms(self, *, id: int) -> DeleteResult:
|
||||
payload = await self._call("deleteSMS", {"id": id})
|
||||
self._check_status(payload, "deleteSMS")
|
||||
return DeleteResult()
|
||||
|
||||
async def delete_mms(self, *, id: int) -> DeleteResult:
|
||||
payload = await self._call("deleteMMS", {"id": id})
|
||||
self._check_status(payload, "deleteMMS")
|
||||
return DeleteResult()
|
||||
47
sms/config.py
Normal file
47
sms/config.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Configuration for the voip.ms SMS client, loaded from environment.
|
||||
|
||||
Set credentials before running:
|
||||
export VOIPMS_API_USERNAME=...
|
||||
export VOIPMS_API_PASSWORD=...
|
||||
|
||||
On the voip.ms side you must also whitelist this machine's public IP under
|
||||
Main Menu -> SOAP / REST API -> API Security, and the sending DID must have
|
||||
SMS enabled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="voipms_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
api_username: str = Field(..., description="voip.ms API username")
|
||||
api_password: str = Field(..., description="voip.ms API password (set in API Security)")
|
||||
|
||||
base_url: str = Field(
|
||||
"https://voip.ms/api/v1/rest.php",
|
||||
description="voip.ms REST endpoint. Avoid www.voip.ms (302 drops POST bodies).",
|
||||
)
|
||||
|
||||
dialing_mode: Literal["nanpa", "e164"] = Field(
|
||||
"nanpa",
|
||||
description="Number format. nanpa=10 digits; e164=+1 then 10 digits.",
|
||||
)
|
||||
|
||||
daily_limit: int = Field(
|
||||
100,
|
||||
description="Per-UTC-day cap on outbound sends (matches the upstream API limit).",
|
||||
ge=0,
|
||||
)
|
||||
|
||||
timeout: float = Field(30.0, description="HTTP timeout in seconds.", ge=1.0)
|
||||
135
sms/docs/voipms-sms-api.dokuwiki.txt
Normal file
135
sms/docs/voipms-sms-api.dokuwiki.txt
Normal file
@@ -0,0 +1,135 @@
|
||||
====== voip.ms SMS/MMS REST API ======
|
||||
|
||||
Reference for the voip.ms SMS/MMS REST API. Sanitized — no account credentials or phone numbers. Account-specific values (API username/password, DID numbers) are supplied at runtime via environment variables and kept local.
|
||||
|
||||
===== Endpoint & authentication =====
|
||||
|
||||
* **Base URL:** ''https://voip.ms/api/v1/rest.php'' (use ''voip.ms'', **not** ''www.voip.ms'' — the ''www'' host 302-redirects and drops POST bodies, producing ''missing_method'').
|
||||
* **Auth (every request):** ''api_username'', ''api_password'', ''method''.
|
||||
- ''api_username'' is the **portal login email** — //not// the 6-digit account ID.
|
||||
- ''api_password'' is set under **Main Menu → SOAP / REST API → API Security** and is distinct from the portal login password.
|
||||
- The calling server's public IP must be **whitelisted** on the same API Security page.
|
||||
* **Transport:** GET with query params for SMS; POST ''multipart/form-data'' for MMS with base64 images.
|
||||
* A separate **bearer token / API key** exists for the 3CX webhook flow — it is **not** used by ''rest.php''.
|
||||
|
||||
===== Limits & pricing =====
|
||||
|
||||
* **API sending cap: 100 SMS/MMS per day** (portal is unlimited). Raise via support ticket.
|
||||
* **SMS:** $0.0075 each way. **MMS:** $0.02 each way.
|
||||
* US/Canada 10-digit DIDs only; short codes and 2FA codes aren't guaranteed; public URL shorteners (bit.ly etc.) may be blocked.
|
||||
* MMS attachments: types ''JPG, GIF, JPEG, PNG, MP3, WAV, MIDI, MP4, 3GP''; ≤1300 KB each; up to 3 files; text ≤2048 chars.
|
||||
|
||||
===== Methods =====
|
||||
|
||||
==== sendSMS — send a text ====
|
||||
|
||||
^ Param ^ Req ^ Notes ^
|
||||
| did | yes | Sender DID, e.g. ''5551234567'' |
|
||||
| dst | yes | Destination number |
|
||||
| message | yes | Max **160** chars |
|
||||
|
||||
Returns ''{"status":"success","sms":<new id>}''. Requires SMS enabled on the DID (see ''setSMS'' below), else ''{"status":"sms_failed","message":"The SMS message was not sent"}''.
|
||||
|
||||
==== sendMMS — send media/text ====
|
||||
|
||||
^ Param ^ Req ^ Notes ^
|
||||
| did | yes | Sender DID |
|
||||
| dst | yes | Destination |
|
||||
| message | yes | Max **2048** chars |
|
||||
| media1 | no | URL to a media file |
|
||||
| media2 | no | Base64 image (''data:image/png;base64,...'') |
|
||||
| media3 | no | Reserved/empty |
|
||||
|
||||
* POST + base64 → up to **1.2 MB/file**; GET + base64 → ~160 KB (~512-char URL limit). File-URL submissions have no size limit either way.
|
||||
|
||||
==== getSMS — list/retrieve texts ====
|
||||
|
||||
^ Param ^ Req ^ Notes ^
|
||||
| sms | no | ID for a specific SMS |
|
||||
| from | no | Start date ''YYYY-MM-DD'' (default: today) |
|
||||
| to | no | End date (default: today) |
|
||||
| type | no | ''1''=received, ''0''=sent |
|
||||
| did | no | Filter by DID |
|
||||
| contact | no | Filter by contact number |
|
||||
| limit | no | Records to return (default **50**) |
|
||||
| timezone | no | Numeric -12 to 13 |
|
||||
| all_messages | no | ''1'' = SMS + MMS combined, ''0'' = SMS only (id must be ''0'' when set) |
|
||||
|
||||
Returns ''{"status":"success","sms":[ ...rows... ]}''. **Empty result returns ''{"status":"no_sms","message":"There are no SMS messages"}''** — treat as ''[]'', not an error.
|
||||
|
||||
==== getMMS — list/retrieve media messages ====
|
||||
|
||||
Same shape as ''getSMS'' but the id param is ''mms''. ''all_messages=1'' returns MMS+SMS combined (id must be ''0''). Empty result also reports ''status: no_sms''.
|
||||
|
||||
==== getMediaMMS — fetch attachments for a message ====
|
||||
|
||||
^ Param ^ Req ^ Notes ^
|
||||
| id | yes | MMS id |
|
||||
| media_as_array | no | ''1''=array, ''0''=JSON object (default) |
|
||||
|
||||
Returns ''{"status":"success","id":...,"date":...,"media":[url,...]}'' (up to 3 slots).
|
||||
|
||||
==== deleteSMS / deleteMMS — delete one message ====
|
||||
|
||||
^ Param ^ Req ^ Notes ^
|
||||
| id | yes | Message id |
|
||||
|
||||
Returns ''{"status":"success"}''.
|
||||
|
||||
==== setSMS — enable/disable SMS on a DID ====
|
||||
|
||||
^ Param ^ Req ^ Notes ^
|
||||
| did | yes | DID to update |
|
||||
| enable | yes | ''1'' to enable, ''0'' to disable |
|
||||
| email_enabled | no | bool — forward inbound SMS to email |
|
||||
| email_address | no | email address(es), comma-separated |
|
||||
| sms_forward_enable | no | bool — forward inbound SMS to another number |
|
||||
| sms_forward | no | forwarding number |
|
||||
| url_callback_enable | no | bool — GET callback on inbound |
|
||||
| url_callback | no | callback URL (variables below) |
|
||||
| url_callback_retry | no | bool — require ''ok'' response, retry every 30 min |
|
||||
|
||||
SMS must be enabled on a DID (''enable=1'') before ''sendSMS'' works.
|
||||
|
||||
==== getDIDsInfo — list DIDs on the account ====
|
||||
|
||||
Returns ''{"status":"success","dids":[ ... ]}''. Each DID includes ''sms_available'', ''sms_enabled'', ''mms_available'', and the full SMS config fields — use this to find a SMS-capable DID and check whether SMS is enabled.
|
||||
|
||||
Note: ''getDIDs'', ''getDIDInfo'', ''getDID'' are **not** valid methods — use ''getDIDsInfo''.
|
||||
|
||||
===== Inbound via URL Callback (per-DID) =====
|
||||
|
||||
Configure on the DID (portal Manage DID, or ''setSMS''):
|
||||
|
||||
<code>
|
||||
https://your.host/sms?to={TO}&from={FROM}&message={MESSAGE}&id={ID}&date={TIMESTAMP}&media={MEDIA}
|
||||
</code>
|
||||
|
||||
Variables: ''{ID}'', ''{TIMESTAMP}'', ''{FROM}'', ''{TO}'', ''{MESSAGE}'', ''{MEDIA}'' (comma-separated media list, for MMS).
|
||||
|
||||
**Retry:** if "URL Callback Retry" is enabled, your endpoint must respond with the literal body ''ok''. Without it, voip.ms re-sends the same message **every 30 minutes**.
|
||||
|
||||
===== Dialing mode (per-DID, for 3CX/API) =====
|
||||
|
||||
Choose NANPA (10-digit, no country code) or E164 (''+1'' + 10 digits). Affects what you send to the API and what arrives in callbacks — normalize on your side.
|
||||
|
||||
===== Gotchas =====
|
||||
|
||||
* ''api_username'' is the portal **email**, not the account ID.
|
||||
* Empty ''getSMS''/''getMMS'' results report ''status: no_sms'' (even for MMS) — return ''[]''.
|
||||
* SMS must be enabled per-DID (''setSMS enable=1'') before sending, else ''sms_failed''.
|
||||
* ''sendSMS''/''sendMMS'' reuse the ''sms'' response key for the new message id (int); ''getSMS'' uses ''sms'' for the row list; ''getMMS'' uses ''mms''.
|
||||
* No ''markSMSRead'' method exists.
|
||||
* SIP/SMS over SIP MESSAGE is text-only (no MMS) and uses a separate SIP sub-account, not the REST API.
|
||||
* DID **POP must match** the registered SIP server or inbound calls won't ring.
|
||||
* Transient transport errors occur; clients should retry with backoff.
|
||||
|
||||
===== Other ingestion channels (non-REST) =====
|
||||
|
||||
* **Email to SMS:** mail ''sms@voip.ms'', subject = recipient 10-digit number; security-code / from-DID overrides via dot syntax ''dst.seccode.fromdid''.
|
||||
* **SMPP:** ''smpp.voip.ms:2775'' (plain) / '':3550'' (TLS, scheme ''ssmpp''); bind as transceiver; ''source_addr'' = SMS-enabled DID. Bypasses the 100/day REST cap.
|
||||
* **SIP MESSAGE (RFC 3428):** SMS only, user/pass-auth trunks; original destination in ''X-Sms-To'' header on receive.
|
||||
|
||||
----
|
||||
|
||||
//Source: voip.ms official API docs (''/m/apidocs.php'') + SMS/MMS wiki article, verified live.//
|
||||
152
sms/docs/voipms-sms-api.md
Normal file
152
sms/docs/voipms-sms-api.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# voip.ms SMS/MMS REST API
|
||||
|
||||
Reference for the voip.ms SMS/MMS REST API. Sanitized — no account credentials or
|
||||
phone numbers. Account-specific values (API username/password, DID numbers, etc.)
|
||||
are kept local and supplied at runtime via environment variables.
|
||||
|
||||
## Endpoint & authentication
|
||||
|
||||
- **Base URL:** `https://voip.ms/api/v1/rest.php` (use `voip.ms`, **not** `www.voip.ms` —
|
||||
the `www` host 302-redirects and drops POST bodies, producing `missing_method`).
|
||||
- **Auth (every request):** `api_username`, `api_password`, `method`.
|
||||
- `api_username` is the **portal login email** — *not* the 6-digit account ID.
|
||||
- `api_password` is set under **Main Menu → SOAP / REST API → API Security** and is
|
||||
distinct from the portal login password.
|
||||
- The calling server's public IP must be **whitelisted** on the same API Security page.
|
||||
- **Transport:** GET with query params for SMS; POST `multipart/form-data` for MMS with
|
||||
base64 images.
|
||||
- A separate **bearer token / API key** exists for the 3CX webhook flow — it is **not**
|
||||
used by `rest.php`.
|
||||
|
||||
## Limits & pricing
|
||||
|
||||
- **API sending cap: 100 SMS/MMS per day** (portal is unlimited). Raise via support ticket.
|
||||
- **SMS:** $0.0075 each way. **MMS:** $0.02 each way.
|
||||
- US/Canada 10-digit DIDs only; short codes and 2FA codes aren't guaranteed; public URL
|
||||
shorteners (bit.ly etc.) may be blocked.
|
||||
- MMS attachments: types `JPG, GIF, JPEG, PNG, MP3, WAV, MIDI, MP4, 3GP`; ≤1300 KB each;
|
||||
up to 3 files; text ≤2048 chars.
|
||||
|
||||
## Methods
|
||||
|
||||
### `sendSMS` — send a text
|
||||
| Param | Req | Notes |
|
||||
|---|---|---|
|
||||
| `did` | yes | Sender DID, e.g. `5551234567` |
|
||||
| `dst` | yes | Destination number |
|
||||
| `message` | yes | Max **160** chars |
|
||||
|
||||
Returns `{"status":"success","sms":<new id>}`. Requires SMS enabled on the DID
|
||||
(see `setSMS` below), else `{"status":"sms_failed","message":"The SMS message was not sent"}`.
|
||||
|
||||
### `sendMMS` — send media/text
|
||||
| Param | Req | Notes |
|
||||
|---|---|---|
|
||||
| `did` | yes | Sender DID |
|
||||
| `dst` | yes | Destination |
|
||||
| `message` | yes | Max **2048** chars |
|
||||
| `media1` | no | URL to a media file |
|
||||
| `media2` | no | Base64 image (`data:image/png;base64,...`) |
|
||||
| `media3` | no | Reserved/empty |
|
||||
|
||||
- POST + base64 → up to **1.2 MB/file**; GET + base64 → ~160 KB (~512-char URL limit).
|
||||
File-URL submissions have no size limit either way.
|
||||
|
||||
### `getSMS` — list/retrieve texts
|
||||
| Param | Req | Notes |
|
||||
|---|---|---|
|
||||
| `sms` | no | ID for a specific SMS |
|
||||
| `from` | no | Start date `YYYY-MM-DD` (default: today) |
|
||||
| `to` | no | End date (default: today) |
|
||||
| `type` | no | `1`=received, `0`=sent |
|
||||
| `did` | no | Filter by DID |
|
||||
| `contact` | no | Filter by contact number |
|
||||
| `limit` | no | Records to return (default **50**) |
|
||||
| `timezone` | no | Numeric -12 to 13 |
|
||||
| `all_messages` | no | `1` = SMS + MMS combined, `0` = SMS only (id must be `0` when set) |
|
||||
|
||||
Returns `{"status":"success","sms":[ ...rows... ]}`. **Empty result returns
|
||||
`{"status":"no_sms","message":"There are no SMS messages"}`** — treat as `[]`, not an error.
|
||||
|
||||
### `getMMS` — list/retrieve media messages
|
||||
Same shape as `getSMS` but the id param is `mms`. `all_messages=1` returns MMS+SMS
|
||||
combined (id must be `0`). Empty result also reports `status: no_sms`.
|
||||
|
||||
### `getMediaMMS` — fetch attachments for a message
|
||||
| Param | Req | Notes |
|
||||
|---|---|---|
|
||||
| `id` | yes | MMS id |
|
||||
| `media_as_array` | no | `1`=array, `0`=JSON object (default) |
|
||||
|
||||
Returns `{"status":"success","id":...,"date":...,"media":[url,...]}` (up to 3 slots).
|
||||
|
||||
### `deleteSMS` / `deleteMMS` — delete one message
|
||||
| Param | Req | Notes |
|
||||
|---|---|---|
|
||||
| `id` | yes | Message id |
|
||||
|
||||
Returns `{"status":"success"}`.
|
||||
|
||||
### `setSMS` — enable/disable SMS on a DID
|
||||
| Param | Req | Notes |
|
||||
|---|---|---|
|
||||
| `did` | yes | DID to update |
|
||||
| `enable` | yes | `1` to enable, `0` to disable |
|
||||
| `email_enabled` | no | bool — forward inbound SMS to email |
|
||||
| `email_address` | no | email address(es), comma-separated |
|
||||
| `sms_forward_enable` | no | bool — forward inbound SMS to another number |
|
||||
| `sms_forward` | no | forwarding number |
|
||||
| `url_callback_enable` | no | bool — GET callback on inbound |
|
||||
| `url_callback` | no | callback URL (variables below) |
|
||||
| `url_callback_retry` | no | bool — require `ok` response, retry every 30 min |
|
||||
|
||||
SMS must be enabled on a DID (`enable=1`) before `sendSMS` works.
|
||||
|
||||
### `getDIDsInfo` — list DIDs on the account
|
||||
Returns `{"status":"success","dids":[ ... ]}`. Each DID includes `sms_available`,
|
||||
`sms_enabled`, `mms_available`, and the full SMS config fields — use this to find a
|
||||
SMS-capable DID and check whether SMS is enabled.
|
||||
|
||||
> Note: `getDIDs`, `getDIDInfo`, `getDID` are **not** valid methods — use `getDIDsInfo`.
|
||||
|
||||
## Inbound via URL Callback (per-DID)
|
||||
|
||||
Configure on the DID (portal Manage DID, or `setSMS`):
|
||||
```
|
||||
https://your.host/sms?to={TO}&from={FROM}&message={MESSAGE}&id={ID}&date={TIMESTAMP}&media={MEDIA}
|
||||
```
|
||||
Variables: `{ID}`, `{TIMESTAMP}`, `{FROM}`, `{TO}`, `{MESSAGE}`, `{MEDIA}` (comma-separated
|
||||
media list, for MMS).
|
||||
|
||||
**Retry:** if "URL Callback Retry" is enabled, your endpoint must respond with the literal
|
||||
body `ok`. Without it, voip.ms re-sends the same message **every 30 minutes**.
|
||||
|
||||
## Dialing mode (per-DID, for 3CX/API)
|
||||
|
||||
Choose NANPA (10-digit, no country code) or E164 (`+1` + 10 digits). Affects what you send
|
||||
to the API and what arrives in callbacks — normalize on your side.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `api_username` is the portal **email**, not the account ID.
|
||||
- Empty `getSMS`/`getMMS` results report `status: no_sms` (even for MMS) — return `[]`.
|
||||
- SMS must be enabled per-DID (`setSMS enable=1`) before sending, else `sms_failed`.
|
||||
- `sendSMS`/`sendMMS` reuse the `sms` response key for the new message id (int); `getSMS`
|
||||
uses `sms` for the row list; `getMMS` uses `mms`.
|
||||
- No `markSMSRead` method exists.
|
||||
- SIP/SMS over SIP MESSAGE is text-only (no MMS) and uses a separate SIP sub-account, not
|
||||
the REST API.
|
||||
- DID **POP must match** the registered SIP server or inbound calls won't ring.
|
||||
- Transient transport errors occur; clients should retry with backoff.
|
||||
|
||||
## Other ingestion channels (non-REST)
|
||||
|
||||
- **Email to SMS:** mail `sms@voip.ms`, subject = recipient 10-digit number; security-code
|
||||
/ from-DID overrides via dot syntax `dst.seccode.fromdid`.
|
||||
- **SMPP:** `smpp.voip.ms:2775` (plain) / `:3550` (TLS, scheme `ssmpp`); bind as
|
||||
transceiver; `source_addr` = SMS-enabled DID. Bypasses the 100/day REST cap.
|
||||
- **SIP MESSAGE (RFC 3428):** SMS only, user/pass-auth trunks; original destination in
|
||||
`X-Sms-To` header on receive.
|
||||
|
||||
---
|
||||
*Source: voip.ms official API docs (`/m/apidocs.php`) + SMS/MMS wiki article, verified live.*
|
||||
26
sms/exceptions.py
Normal file
26
sms/exceptions.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Exception hierarchy for the voip.ms SMS client."""
|
||||
|
||||
|
||||
class VoipMsError(Exception):
|
||||
"""Base error for any failure talking to the voip.ms API."""
|
||||
|
||||
|
||||
class VoipMsAuthError(VoipMsError):
|
||||
"""Authentication / IP-whitelist problem reported by voip.ms."""
|
||||
|
||||
|
||||
class VoipMsApiError(VoipMsError):
|
||||
"""voip.ms returned a non-success `status` in the response body."""
|
||||
|
||||
def __init__(self, message: str, code: str | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
class VoipMsRateLimitError(VoipMsError):
|
||||
"""Local daily-send guard refused the request before it was sent."""
|
||||
|
||||
def __init__(self, message: str, *, limit: int, used_today: int) -> None:
|
||||
super().__init__(message)
|
||||
self.limit = limit
|
||||
self.used_today = used_today
|
||||
74
sms/guard.py
Normal file
74
sms/guard.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Outbound send guard: per-UTC-day counter + structured logging.
|
||||
|
||||
The voip.ms API caps sending at 100 SMS/MMS per day. This guard refuses
|
||||
sends beyond a configurable limit (default 100) before they hit the network,
|
||||
and logs every outbound send.
|
||||
|
||||
Note: this counter is per-process. If you run multiple instances behind a
|
||||
load balancer, replace it with a shared store (Redis, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .exceptions import VoipMsRateLimitError
|
||||
|
||||
logger = logging.getLogger("voipms.sms.guard")
|
||||
|
||||
|
||||
class DailySendCounter:
|
||||
"""Counts outbound sends per UTC day, refusing sends past the limit."""
|
||||
|
||||
def __init__(self, limit: int) -> None:
|
||||
self._limit = limit
|
||||
self._date: str | None = None
|
||||
self._count = 0
|
||||
|
||||
@staticmethod
|
||||
def _today() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
@property
|
||||
def used_today(self) -> int:
|
||||
if self._date != self._today():
|
||||
return 0
|
||||
return self._count
|
||||
|
||||
@property
|
||||
def limit(self) -> int:
|
||||
return self._limit
|
||||
|
||||
def check_and_increment(self) -> int:
|
||||
"""Raise VoipMsRateLimitError if the daily cap is hit; otherwise increment.
|
||||
|
||||
Returns the count after this send.
|
||||
"""
|
||||
today = self._today()
|
||||
if self._date != today:
|
||||
self._date = today
|
||||
self._count = 0
|
||||
|
||||
if self._count >= self._limit:
|
||||
raise VoipMsRateLimitError(
|
||||
f"daily send limit ({self._limit}) reached",
|
||||
limit=self._limit,
|
||||
used_today=self._count,
|
||||
)
|
||||
|
||||
self._count += 1
|
||||
return self._count
|
||||
|
||||
def log_send(self, *, kind: str, did: str, dst: str, chars: int) -> None:
|
||||
remaining = self._limit - self._count
|
||||
logger.info(
|
||||
"voipms_send kind=%s did=%s dst=%s chars=%d used_today=%d limit=%d remaining=%d",
|
||||
kind,
|
||||
did,
|
||||
dst,
|
||||
chars,
|
||||
self._count,
|
||||
self._limit,
|
||||
remaining,
|
||||
)
|
||||
76
sms/models.py
Normal file
76
sms/models.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Pydantic models for voip.ms SMS/MMS request and response payloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SendResult(BaseModel):
|
||||
"""Result of a successful sendSMS / sendMMS call."""
|
||||
|
||||
id: int = Field(..., description="The new SMS/MMS id assigned by voip.ms.")
|
||||
|
||||
|
||||
class SmsRecord(BaseModel):
|
||||
"""A single SMS row as returned by getSMS.
|
||||
|
||||
voip.ms returns a flat object per message; only the commonly-used fields
|
||||
are typed. Extra fields are ignored so the model stays forward-compatible.
|
||||
"""
|
||||
|
||||
id: str | int
|
||||
date: str | None = None
|
||||
type: str | None = Field(None, description="e.g. '1' received / '0' sent")
|
||||
did: str | None = None
|
||||
contact: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
|
||||
class MmsRecord(BaseModel):
|
||||
"""A single MMS row as returned by getMMS."""
|
||||
|
||||
id: str | int
|
||||
date: str | None = None
|
||||
type: str | None = None
|
||||
did: str | None = None
|
||||
contact: str | None = None
|
||||
message: str | None = None
|
||||
media: str | None = Field(None, description="Comma-separated media file list, if present.")
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
|
||||
class MediaResult(BaseModel):
|
||||
"""Media attachments for one MMS, from getMediaMMS."""
|
||||
|
||||
id: str | int
|
||||
date: str | None = None
|
||||
media: list[str] = Field(default_factory=list, description="Up to 3 media URLs/slots.")
|
||||
|
||||
|
||||
class DeleteResult(BaseModel):
|
||||
"""Result of deleteSMS / deleteMMS."""
|
||||
|
||||
status: Literal["success"] = "success"
|
||||
|
||||
|
||||
# --- Request bodies for the FastAPI routes ---------------------------------
|
||||
|
||||
|
||||
class SendSmsRequest(BaseModel):
|
||||
did: str = Field(..., description="Sender DID (10 digits, or +1... for e164).")
|
||||
dst: str = Field(..., description="Destination number.")
|
||||
message: str = Field(..., max_length=160)
|
||||
|
||||
|
||||
class SendMmsRequest(BaseModel):
|
||||
did: str
|
||||
dst: str
|
||||
message: str = Field(..., max_length=2048)
|
||||
media1: str | None = Field(None, description="URL to a media file.")
|
||||
media2: str | None = Field(None, description="Base64-encoded image (data:image/...;base64,...).")
|
||||
media3: str | None = Field(None, description="Reserved; usually empty.")
|
||||
5
sms/requirements.txt
Normal file
5
sms/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.27
|
||||
httpx>=0.27
|
||||
pydantic>=2.6
|
||||
pydantic-settings>=2.2
|
||||
Reference in New Issue
Block a user