commit db5bde0a707a29e5e43d28c8b5c27f3e1a4d71ce Author: chelsea Date: Sat Jul 4 04:39:53 2026 +0000 first commit Co-Authored-By: Claude diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21b8ac3 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/sms/README.md b/sms/README.md new file mode 100644 index 0000000..e7352a3 --- /dev/null +++ b/sms/README.md @@ -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}`. \ No newline at end of file diff --git a/sms/__init__.py b/sms/__init__.py new file mode 100644 index 0000000..f3d3ad7 --- /dev/null +++ b/sms/__init__.py @@ -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", +] \ No newline at end of file diff --git a/sms/app.py b/sms/app.py new file mode 100644 index 0000000..9228118 --- /dev/null +++ b/sms/app.py @@ -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" \ No newline at end of file diff --git a/sms/client.py b/sms/client.py new file mode 100644 index 0000000..39cfa76 --- /dev/null +++ b/sms/client.py @@ -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() \ No newline at end of file diff --git a/sms/config.py b/sms/config.py new file mode 100644 index 0000000..0bf5f57 --- /dev/null +++ b/sms/config.py @@ -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) \ No newline at end of file diff --git a/sms/docs/voipms-sms-api.dokuwiki.txt b/sms/docs/voipms-sms-api.dokuwiki.txt new file mode 100644 index 0000000..4fe4b33 --- /dev/null +++ b/sms/docs/voipms-sms-api.dokuwiki.txt @@ -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":}''. 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.// \ No newline at end of file diff --git a/sms/docs/voipms-sms-api.md b/sms/docs/voipms-sms-api.md new file mode 100644 index 0000000..97086d9 --- /dev/null +++ b/sms/docs/voipms-sms-api.md @@ -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":}`. 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.* \ No newline at end of file diff --git a/sms/exceptions.py b/sms/exceptions.py new file mode 100644 index 0000000..ce205e9 --- /dev/null +++ b/sms/exceptions.py @@ -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 \ No newline at end of file diff --git a/sms/guard.py b/sms/guard.py new file mode 100644 index 0000000..1edf3ca --- /dev/null +++ b/sms/guard.py @@ -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, + ) \ No newline at end of file diff --git a/sms/models.py b/sms/models.py new file mode 100644 index 0000000..b20ded8 --- /dev/null +++ b/sms/models.py @@ -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.") \ No newline at end of file diff --git a/sms/requirements.txt b/sms/requirements.txt new file mode 100644 index 0000000..3d09c89 --- /dev/null +++ b/sms/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.110 +uvicorn[standard]>=0.27 +httpx>=0.27 +pydantic>=2.6 +pydantic-settings>=2.2 \ No newline at end of file