"""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 Tenant, Worker 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 """ # 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}}) 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 ) ) 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()