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>
This commit is contained in:
75
gogo/sms/gateway.py
Normal file
75
gogo/sms/gateway.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""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]}")
|
||||
@@ -19,7 +19,16 @@ from sqlalchemy import select
|
||||
|
||||
from gogo.config import get_settings
|
||||
from gogo.db import get_sessionmaker
|
||||
from gogo.models import Tenant, Worker
|
||||
from gogo.models import PhoneNumber, Tenant, Worker
|
||||
|
||||
|
||||
def gateway_line_password(msisdn: str) -> str:
|
||||
"""Deterministic per-SIM SIP secret (no extra storage; derived from the app key)."""
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha256(
|
||||
f"{get_settings().secret_key}:gw:{msisdn}".encode()
|
||||
).hexdigest()[:16]
|
||||
|
||||
PJSIP_HEADER = """\
|
||||
; -- generated by gogo.telephony.asterisk — do not edit by hand --
|
||||
@@ -61,6 +70,35 @@ max_contacts=3
|
||||
remove_existing=yes
|
||||
"""
|
||||
|
||||
# GSM gateway SIP trunk (M5): each SIM line registers as its own endpoint and
|
||||
# lands directly in its tenant's inbound context (1 SIM = 1 salon, §5.1).
|
||||
GATEWAY_LINE_TEMPLATE = """\
|
||||
|
||||
; GSM gateway line {port} → {msisdn} ({tenant})
|
||||
[gw-line-{port}]
|
||||
type=endpoint
|
||||
context={context}
|
||||
disallow=all
|
||||
allow=alaw,ulaw
|
||||
auth=gw-line-{port}-auth
|
||||
aors=gw-line-{port}
|
||||
direct_media=no
|
||||
rtp_symmetric=yes
|
||||
force_rport=yes
|
||||
rewrite_contact=yes
|
||||
|
||||
[gw-line-{port}-auth]
|
||||
type=auth
|
||||
auth_type=userpass
|
||||
username=gw-line-{port}
|
||||
password={password}
|
||||
|
||||
[gw-line-{port}]
|
||||
type=aor
|
||||
max_contacts=1
|
||||
remove_existing=yes
|
||||
"""
|
||||
|
||||
# Test endpoint for M4 software-only testing: a desktop softphone registers as
|
||||
# 'test-caller' and dials any number to reach the tenant's inbound flow (§16 M4).
|
||||
TEST_CALLER_TEMPLATE = """\
|
||||
@@ -188,6 +226,23 @@ async def generate(out_dir: Path, backend_url: str = "", voice_addr: str = "") -
|
||||
)
|
||||
)
|
||||
|
||||
# GSM gateway line for this tenant's SIM (M5)
|
||||
number = (
|
||||
await session.execute(
|
||||
select(PhoneNumber).where(PhoneNumber.tenant_id == tenant.id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if number and number.gateway_port is not None:
|
||||
pjsip.append(
|
||||
GATEWAY_LINE_TEMPLATE.format(
|
||||
port=number.gateway_port,
|
||||
msisdn=number.msisdn,
|
||||
tenant=tenant.slug,
|
||||
context=context,
|
||||
password=gateway_line_password(number.msisdn),
|
||||
)
|
||||
)
|
||||
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
written = []
|
||||
for filename, content in [
|
||||
|
||||
Reference in New Issue
Block a user