Files
gogo-telefon/gogo/telephony/asterisk.py

281 lines
7.9 KiB
Python
Raw Normal View History

"""Asterisk configuration generator (M4): pjsip.conf + extensions.conf.
Generated from the DB (tenants, workers) so the dashboard stays the single
source of truth. Ring-group-first logic (§5.2) lives in the dialplan, but the
decision (targets + timeout, working hours) comes from the backend via the
internal API at call time Asterisk never duplicates tenant config.
Usage: python -m gogo.telephony.asterisk --out ./asterisk/conf
Regenerate + `asterisk -rx 'core reload'` whenever workers change.
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from sqlalchemy import select
from gogo.config import get_settings
from gogo.db import get_sessionmaker
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 --
[global]
type=global
user_agent=GogoTelefon
[transport-udp]
type=transport
protocol=udp
bind=0.0.0.0:5060
"""
ENDPOINT_TEMPLATE = """\
; worker: {name} ({tenant})
[{username}]
type=endpoint
context={context}
disallow=all
allow=alaw,ulaw,slin
auth={username}-auth
aors={username}
direct_media=no
rtp_symmetric=yes
force_rport=yes
rewrite_contact=yes
[{username}-auth]
type=auth
auth_type=userpass
username={username}
password={password}
[{username}]
type=aor
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 = """\
; software-testing caller (softphone) lands in {context}
[test-caller-{slug}]
type=endpoint
context={context}
disallow=all
allow=alaw,ulaw,slin
auth=test-caller-{slug}-auth
aors=test-caller-{slug}
direct_media=no
rtp_symmetric=yes
force_rport=yes
rewrite_contact=yes
[test-caller-{slug}-auth]
type=auth
auth_type=userpass
username=test-caller-{slug}
password={password}
[test-caller-{slug}]
type=aor
max_contacts=3
remove_existing=yes
"""
EXTENSIONS_HEADER = """\
; -- generated by gogo.telefonija.asterisk do not edit by hand --
[globals]
GOGO_BACKEND={backend}
GOGO_VOICE={voice}
GOGO_TOKEN={token}
[gogo-workers]
; workers only receive calls in MVP
exten => _X.,1,Hangup()
"""
TENANT_CONTEXT_TEMPLATE = """\
[{context}]
; inbound flow for {tenant_name} (§5.2): register ring staff agent fallback
exten => _[+0-9].,1,NoOp(Gogo inbound ${{EXTEN}} for {slug})
same => n,Answer()
same => n,Set(REG=${{CURL(${{GOGO_BACKEND}}/internal/calls/register?tenant={slug}&caller=${{URIENCODE(${{CALLERID(num)}})}}&token=${{GOGO_TOKEN}})}})
same => n,GotoIf($["${{REG}}"=""]?fallback)
same => n,Set(GOGO_UUID=${{CUT(REG,\\,,1)}})
same => n,Set(RINGTIME=${{CUT(REG,\\,,2)}})
same => n,Set(TARGETS=${{CUT(REG,\\,,3)}})
same => n,GotoIf($["${{TARGETS}}"=""]?agent)
same => n,Dial(${{TARGETS}},${{RINGTIME}})
same => n,GotoIf($["${{DIALSTATUS}}"="ANSWER"]?answered)
same => n(agent),AudioSocket(${{GOGO_UUID}},${{GOGO_VOICE}})
; if the voice pipeline is down AudioSocket fails instantly fallback (§15);
; after a normal agent call the caller is gone and Playback is a no-op
same => n,Playback(gogo-fallback)
same => n,Hangup()
same => n(answered),Set(RES=${{CURL(${{GOGO_BACKEND}}/internal/calls/${{GOGO_UUID}}/answered?token=${{GOGO_TOKEN}}&duration=${{CDR(billsec)}})}})
same => n,Hangup()
same => n(fallback),Playback(vm-goodbye)
same => n,Hangup()
; caller hung up mid-flow (e.g. while ringing) missed-call handling (§5.5)
exten => h,1,GotoIf($["${{GOGO_UUID}}"=""]?done)
same => n,Set(RES=${{CURL(${{GOGO_BACKEND}}/internal/calls/${{GOGO_UUID}}/hangup?status=${{DIALSTATUS}}&token=${{GOGO_TOKEN}})}})
same => n(done),NoOp(bye)
"""
async def generate(out_dir: Path, backend_url: str = "", voice_addr: str = "") -> list[str]:
"""Write pjsip.conf + extensions.conf; returns list of written files."""
s = get_settings()
backend_url = backend_url or "http://backend:8000"
voice_addr = voice_addr or f"voice:{s.voice_port}"
pjsip = [PJSIP_HEADER]
extensions = [
EXTENSIONS_HEADER.format(backend=backend_url, voice=voice_addr, token=s.internal_token)
]
async with get_sessionmaker()() as session:
tenants = (
(
await session.execute(
select(Tenant).where(Tenant.status == "active").order_by(Tenant.slug)
)
)
.scalars()
.all()
)
for tenant in tenants:
context = f"gogo-inbound-{tenant.slug}"
workers = (
(
await session.execute(
select(Worker)
.where(Worker.tenant_id == tenant.id, Worker.active.is_(True))
.order_by(Worker.sip_username)
)
)
.scalars()
.all()
)
for w in workers:
pjsip.append(
ENDPOINT_TEMPLATE.format(
name=w.name,
tenant=tenant.slug,
username=w.sip_username,
password=w.sip_password,
context="gogo-workers",
)
)
pjsip.append(
TEST_CALLER_TEMPLATE.format(
slug=tenant.slug,
context=context,
password=f"test-{tenant.widget_public_key[:10]}",
)
)
extensions.append(
TENANT_CONTEXT_TEMPLATE.format(
context=context, tenant_name=tenant.name, slug=tenant.slug
)
)
# 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 [
("pjsip.conf", "".join(pjsip)),
("extensions.conf", "".join(extensions)),
]:
path = out_dir / filename
path.write_text(content)
written.append(str(path))
return written
def main() -> None:
out = Path("./asterisk/conf")
backend = ""
voice = ""
args = sys.argv[1:]
while args:
arg = args.pop(0)
if arg == "--out" and args:
out = Path(args.pop(0))
elif arg == "--backend" and args:
backend = args.pop(0)
elif arg == "--voice" and args:
voice = args.pop(0)
written = asyncio.run(generate(out, backend, voice))
for path in written:
print(f"wrote {path}")
if __name__ == "__main__":
main()