Files
gogo-telefon/gogo/sms/gateway.py
Senad Uka de5a2abdeb M5: hardware integration layer (write-only, live test deferred)
- GsmGatewaySmsProvider: GoIP-style HTTP SMS API, per-tenant SIM line from
  phone_numbers.gateway_port; model-specific bits isolated for easy adaptation
  (§17.3); failure never breaks the calling flow (logged to sms_log)
- Asterisk config generator now emits [gw-line-N] SIP trunk endpoints per
  assigned SIM, routed straight into the tenant's inbound context (1 SIM =
  1 salon §5.1); deterministic per-SIM SIP secrets
- docs/HARDWARE_TESTING.md: step-by-step live runbook for the joint session
  (gateway registration, inbound path, SMS via gateway, operator forwarding
  codes per m:tel/BH Telecom/HT Eronet, e2e acceptance, rollback)
- 5 tests against a mocked gateway API
(forwarding-code generator and SIM/port admin UI shipped in M3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 11:09:28 +02:00

76 lines
2.8 KiB
Python

"""GSM gateway SMS provider (M5 — written now, live-tested with hardware later).
Targets GoIP/Yeastar-class multi-SIM gateways with an HTTP SMS API (§17.3).
The default request shape matches the GoIP family:
GET http://<gw>/default/en_US/send.html?u=<user>&p=<pass>&l=<line>&n=<msisdn>&m=<text>
Each tenant sends through its own SIM: the line number comes from
phone_numbers.gateway_port (assigned in the super-admin panel). The exact
gateway model is an open question (§17.3) — everything model-specific is
isolated in `_build_request` / `_check_response` so a different vendor is a
small patch, and HARDWARE_TESTING.md walks through live verification.
"""
from __future__ import annotations
import logging
import uuid
import httpx
from sqlalchemy import select
from gogo.config import get_settings
from gogo.db import get_sessionmaker
from gogo.models import PhoneNumber
log = logging.getLogger("gogo.sms.gateway")
class GatewayError(Exception):
pass
class GsmGatewaySmsProvider:
# test hook (same pattern as PartnerApiProvider)
client_factory = staticmethod(lambda timeout: httpx.AsyncClient(timeout=timeout))
async def send(self, tenant_id: uuid.UUID, to_msisdn: str, body: str) -> None:
line = await self._line_for_tenant(tenant_id)
url, params = self._build_request(line, to_msisdn, body)
async with self.client_factory(timeout=15) as client:
resp = await client.get(url, params=params)
self._check_response(resp)
log.info("SMS sent via gateway line %s to %s", line, to_msisdn)
async def _line_for_tenant(self, tenant_id: uuid.UUID) -> int:
async with get_sessionmaker()() as session:
number = (
await session.execute(
select(PhoneNumber).where(PhoneNumber.tenant_id == tenant_id)
)
).scalar_one_or_none()
if number is None or number.gateway_port is None:
raise GatewayError(f"tenant {tenant_id} has no SIM/gateway port assigned")
return number.gateway_port
def _build_request(self, line: int, to_msisdn: str, body: str) -> tuple[str, dict]:
s = get_settings()
if not s.gsm_gateway_url:
raise GatewayError("GOGO_GSM_GATEWAY_URL is not configured")
url = s.gsm_gateway_url.rstrip("/") + "/default/en_US/send.html"
return url, {
"u": s.gsm_gateway_user,
"p": s.gsm_gateway_password,
"l": str(line),
"n": to_msisdn.lstrip("+"),
"m": body,
}
def _check_response(self, resp: httpx.Response) -> None:
if resp.status_code != 200:
raise GatewayError(f"gateway returned HTTP {resp.status_code}")
text = resp.text.lower()
if "error" in text or "fail" in text:
raise GatewayError(f"gateway rejected SMS: {resp.text[:200]}")