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:
2026-07-11 11:09:28 +02:00
parent 714aa1782b
commit de5a2abdeb
4 changed files with 328 additions and 1 deletions

View File

@@ -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 [