From de5a2abdeb145dda026e2dea5399b64a3e47be5b Mon Sep 17 00:00:00 2001 From: Senad Uka Date: Sat, 11 Jul 2026 11:09:28 +0200 Subject: [PATCH] M5: hardware integration layer (write-only, live test deferred) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/HARDWARE_TESTING.md | 76 +++++++++++++++++++++++ gogo/sms/gateway.py | 75 +++++++++++++++++++++++ gogo/telephony/asterisk.py | 57 ++++++++++++++++- tests/test_gateway_sms.py | 121 +++++++++++++++++++++++++++++++++++++ 4 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 docs/HARDWARE_TESTING.md create mode 100644 gogo/sms/gateway.py create mode 100644 tests/test_gateway_sms.py diff --git a/docs/HARDWARE_TESTING.md b/docs/HARDWARE_TESTING.md new file mode 100644 index 0000000..59b2260 --- /dev/null +++ b/docs/HARDWARE_TESTING.md @@ -0,0 +1,76 @@ +# M5 — Live hardware test plan (joint session with the founder) + +Everything below is already written and unit-tested against mocks; this runbook +verifies it against real hardware: GSM gateway + SIM + operator forwarding. +Estimated time: 2–3 hours with hardware on site. + +## 0. Shopping/prep checklist (founder) + +- [ ] GSM gateway, GoIP/Yeastar class, with **SIP registration** and an + **HTTP SMS API** (§17.3). 4/8 ports depending on pilot count. +- [ ] 1 SIM card per pilot salon (verify operator terms for gateway use, §17.4). +- [ ] Salon's own number reachable for forwarding tests. +- [ ] Server reachable from the gateway (LAN or VPN), ports 5060/UDP + RTP range. + +## 1. Gateway → Asterisk registration + +1. In the super-admin panel → *Brojevi/SIM*: add the SIM's number, set its + gateway port (line) and operator; assign it to the pilot salon. +2. Regenerate Asterisk config and reload: + ```bash + python -m gogo.telephony.asterisk --out ./asterisk/conf \ + --backend http://:8000 --voice :9092 + docker compose --profile voice restart asterisk + ``` + The config now contains a `[gw-line-]` endpoint. The SIP password is + deterministic; print it with: + ```bash + python -c "from gogo.telephony.asterisk import gateway_line_password as p; print(p(''))" + ``` +3. On the gateway, configure line : SIP server = Asterisk host, + username `gw-line-`, the password from above, register. +4. Verify: `asterisk -rx 'pjsip show endpoints'` → `gw-line-` **Avail**. + +## 2. Inbound call path (§5.1) + +- [ ] Call the SIM's number directly from a mobile → gateway → Asterisk → + ring group rings → no answer → agent answers. Full conversation books a + request; check dashboard (Zahtjevi, Pozivi + audio), email, metering. +- [ ] Answer on a worker softphone → outcome `javilo se osoblje`, no minutes. +- [ ] Hang up while ringing → missed-call SMS arrives on the caller's phone. + +## 3. Outbound SMS via gateway HTTP API (§5.5) + +1. Set in `.env`: `GOGO_SMS_PROVIDER=gsm_gateway`, + `GOGO_GSM_GATEWAY_URL=http://`, `GOGO_GSM_GATEWAY_USER/PASSWORD`. +2. Restart backend; from a request in the dashboard press *Potvrdi + SMS*. +- [ ] Confirmation SMS arrives on the client phone, `sms_log.status = sent`. +- [ ] Unplug the gateway → send again → `sms_log.status = failed` with error, + request flow otherwise unaffected (SMS failure never blocks §9). +- [ ] If the gateway is not GoIP-compatible, adapt `_build_request` / + `_check_response` in `gogo/sms/gateway.py` (isolated on purpose). + +## 4. Operator conditional forwarding (§5.1, §17.4) + +On the salon's own phone, per operator (codes shown in dashboard → Telefonija): + +- [ ] m:tel: activate no-answer forwarding (`**61***#`), call + the salon number from a third phone, don't answer → call arrives on SIM. +- [ ] Repeat for busy (`**67*...`) and unreachable (`**62*...`). +- [ ] `##002#` deactivates everything; `*#61#` shows status. +- [ ] Verify the same set on BH Telecom and HT Eronet SIMs; note any operator + quirks in `gogo/telephony/forwarding.py` (per-operator function exists). + +## 5. End-to-end acceptance (Definition of Done §16 + GSM leg) + +- [ ] Client calls salon number → no answer → forwarded → agent conversation → + proposal email/partner push → owner clicks *Riješeno* → done, no SMS. +- [ ] Second run: owner clicks *Potvrdi termin N + SMS* → client receives SMS + via the salon's own SIM. +- [ ] Out-of-hours call → agent answers immediately with the closed-greeting. + +## 6. Rollback + +Forwarding off: `##002#` on the salon phone. Gateway line disable: super-admin +→ Brojevi/SIM → status `blocked` + config regen. The salon's own number keeps +working untouched throughout — Gogo only ever received forwarded calls. diff --git a/gogo/sms/gateway.py b/gogo/sms/gateway.py new file mode 100644 index 0000000..13f8f9b --- /dev/null +++ b/gogo/sms/gateway.py @@ -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:///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]}") diff --git a/gogo/telephony/asterisk.py b/gogo/telephony/asterisk.py index 79f7480..b83c7c6 100644 --- a/gogo/telephony/asterisk.py +++ b/gogo/telephony/asterisk.py @@ -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 [ diff --git a/tests/test_gateway_sms.py b/tests/test_gateway_sms.py new file mode 100644 index 0000000..e316d33 --- /dev/null +++ b/tests/test_gateway_sms.py @@ -0,0 +1,121 @@ +"""M5: GSM gateway SMS provider against a mocked gateway HTTP API + trunk config.""" + +import httpx +import pytest +from fastapi import FastAPI, Request +from sqlalchemy import select + +from gogo.models import PhoneNumber, SmsLog +from gogo.sms.base import send_sms, set_sms_provider +from gogo.sms.gateway import GatewayError, GsmGatewaySmsProvider + +# -- fake gateway (GoIP-style HTTP API) ------------------------------------------ + +fake_gateway = FastAPI() +received: list[dict] = [] + + +@fake_gateway.get("/default/en_US/send.html") +async def send_html(request: Request): + received.append(dict(request.query_params)) + return "Sending" + + +@fake_gateway.get("/fail/default/en_US/send.html") +async def send_fail(request: Request): + return "ERROR: no signal" + + +@pytest.fixture +def gateway_env(monkeypatch): + received.clear() + monkeypatch.setenv("GOGO_GSM_GATEWAY_URL", "http://gw.test") + monkeypatch.setenv("GOGO_GSM_GATEWAY_USER", "admin") + monkeypatch.setenv("GOGO_GSM_GATEWAY_PASSWORD", "gwpass") + from gogo.config import get_settings + + get_settings.cache_clear() + + def factory(timeout): + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=fake_gateway), + base_url="http://gw.test", + timeout=timeout, + ) + + monkeypatch.setattr(GsmGatewaySmsProvider, "client_factory", staticmethod(factory)) + yield + get_settings.cache_clear() + + +async def assign_sim(session, tenant, port=3, msisdn="+38765000111"): + session.add(PhoneNumber(tenant_id=tenant.id, msisdn=msisdn, gateway_port=port)) + await session.commit() + + +async def test_sms_sent_through_tenant_line(session, tenant, gateway_env): + await assign_sim(session, tenant, port=3) + provider = GsmGatewaySmsProvider() + await provider.send(tenant.id, "+38761222333", "Potvrđen termin: sutra u 17h") + + assert len(received) == 1 + params = received[0] + assert params["u"] == "admin" + assert params["p"] == "gwpass" + assert params["l"] == "3" # the tenant's SIM line + assert params["n"] == "38761222333" # no leading + + assert "Potvrđen termin" in params["m"] + + +async def test_no_sim_assigned_raises(session, tenant, gateway_env): + provider = GsmGatewaySmsProvider() + with pytest.raises(GatewayError, match="no SIM"): + await provider.send(tenant.id, "+38761222333", "test") + + +async def test_gateway_error_body_raises(session, tenant, gateway_env, monkeypatch): + await assign_sim(session, tenant) + + def factory(timeout): + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=fake_gateway), + base_url="http://gw.test/fail", + timeout=timeout, + ) + + monkeypatch.setenv("GOGO_GSM_GATEWAY_URL", "http://gw.test/fail") + from gogo.config import get_settings + + get_settings.cache_clear() + monkeypatch.setattr(GsmGatewaySmsProvider, "client_factory", staticmethod(factory)) + + provider = GsmGatewaySmsProvider() + with pytest.raises(GatewayError, match="rejected"): + await provider.send(tenant.id, "+38761222333", "test") + + +async def test_send_sms_wrapper_logs_failure_without_raising( + session, tenant, gateway_env +): + """SMS failure must never break the calling flow (§9) — logged as failed.""" + set_sms_provider(GsmGatewaySmsProvider()) # no SIM assigned → will fail + try: + ok = await send_sms(session, tenant.id, "+38761222333", "test", kind="confirmation") + await session.commit() + finally: + set_sms_provider(None) + assert ok is False + row = (await session.execute(select(SmsLog))).scalar_one() + assert row.status == "failed" + assert "no SIM" in row.error + + +async def test_asterisk_config_includes_gateway_line(session, tenant, tmp_path): + from gogo.telephony.asterisk import gateway_line_password, generate + + await assign_sim(session, tenant, port=2, msisdn="+38765000222") + await generate(tmp_path) + pjsip = (tmp_path / "pjsip.conf").read_text() + assert "[gw-line-2]" in pjsip + assert "context=gogo-inbound-salon-merima" in pjsip + assert f"password={gateway_line_password('+38765000222')}" in pjsip