"""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:///default/en_US/send.html?u=&p=&l=&n=&m= 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]}")