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:
76
docs/HARDWARE_TESTING.md
Normal file
76
docs/HARDWARE_TESTING.md
Normal file
@@ -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://<backend>:8000 --voice <voice-host>:9092
|
||||||
|
docker compose --profile voice restart asterisk
|
||||||
|
```
|
||||||
|
The config now contains a `[gw-line-<port>]` endpoint. The SIP password is
|
||||||
|
deterministic; print it with:
|
||||||
|
```bash
|
||||||
|
python -c "from gogo.telephony.asterisk import gateway_line_password as p; print(p('<msisdn>'))"
|
||||||
|
```
|
||||||
|
3. On the gateway, configure line <port>: SIP server = Asterisk host,
|
||||||
|
username `gw-line-<port>`, the password from above, register.
|
||||||
|
4. Verify: `asterisk -rx 'pjsip show endpoints'` → `gw-line-<port>` **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://<gateway-ip>`, `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*<gogo-number>**<sec>#`), 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.
|
||||||
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.config import get_settings
|
||||||
from gogo.db import get_sessionmaker
|
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 = """\
|
PJSIP_HEADER = """\
|
||||||
; -- generated by gogo.telephony.asterisk — do not edit by hand --
|
; -- generated by gogo.telephony.asterisk — do not edit by hand --
|
||||||
@@ -61,6 +70,35 @@ max_contacts=3
|
|||||||
remove_existing=yes
|
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 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' and dials any number to reach the tenant's inbound flow (§16 M4).
|
||||||
TEST_CALLER_TEMPLATE = """\
|
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)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
written = []
|
written = []
|
||||||
for filename, content in [
|
for filename, content in [
|
||||||
|
|||||||
121
tests/test_gateway_sms.py
Normal file
121
tests/test_gateway_sms.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user