18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Secrets — never commit
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Claude Code local state
|
||||||
|
.claude/
|
||||||
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