M4: voice pipeline (software-only telephony)
- AudioSocket server (asyncio TCP, Asterisk wire protocol) bridging calls into the same M2 agent used by chat - Call session engine: greeting → energy-VAD utterance collection → STT → agent turn → TTS playback with barge-in (120ms of caller speech stops playback); inactivity + max-duration guards; unintelligible audio reaches the agent as '[nerazumljivo]' so the two-attempt rule stays in the prompt - STTProvider (faster-whisper, lang hint 'sr', GPU/CPU via env) and TTSProvider (Azure Neural raw-8k PCM / ElevenLabs Flash) + test fakes - Recording (mixed caller+agent WAV), transcripts, per-turn latency trace, metering via record_agent_call (§12) - Internal dialplan API: /internal/calls/register (ring targets computed from working hours + ring settings §5.2), /answered (human outcome, no minutes), /hangup (abandoned → missed-call SMS §5.5); shared-token auth - Asterisk config generator (pjsip.conf + extensions.conf from DB): worker endpoints, per-tenant test-caller endpoint, register→Dial→AudioSocket dialplan with h-extension reporting — validated against a real Asterisk 20 container (modules, dialplan, endpoints all load) - docker-compose 'voice' profile (voice server + Asterisk), Dockerfile.voice, docs/VOICE_TESTING.md softphone runbook - 16 new tests incl. full fake-call e2e and a real-TCP AudioSocket wire test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
222
gogo/telephony/asterisk.py
Normal file
222
gogo/telephony/asterisk.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user