513 lines
18 KiB
Python
513 lines
18 KiB
Python
|
|
"""M4 voice pipeline: VAD, audio utils, full fake-call e2e (greeting → utterance
|
||
|
|
→ STT → agent → TTS → booking request → finalize with recording + metering),
|
||
|
|
barge-in, internal dialplan API, Asterisk config generation."""
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import uuid
|
||
|
|
from datetime import datetime, timedelta
|
||
|
|
from zoneinfo import ZoneInfo
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from sqlalchemy import select
|
||
|
|
|
||
|
|
from gogo.agent.llm import LLMResponse, ScriptedLLM, ToolUse, set_llm
|
||
|
|
from gogo.models import BookingRequest, Call, CallRegistration, UsageCounter, Worker
|
||
|
|
from gogo.voice.audio import FRAME_BYTES, CallRecorder, mix, pcm_to_wav, silence, tone
|
||
|
|
from gogo.voice.call import CallLimits, CallSession, Transport
|
||
|
|
from gogo.voice.stt import FakeSTT, set_stt
|
||
|
|
from gogo.voice.tts import FakeTTS, set_tts
|
||
|
|
from gogo.voice.vad import UtteranceCollector
|
||
|
|
|
||
|
|
TZ = ZoneInfo("Europe/Sarajevo")
|
||
|
|
|
||
|
|
|
||
|
|
def frames(pcm: bytes) -> list[bytes]:
|
||
|
|
return [pcm[i : i + FRAME_BYTES] for i in range(0, len(pcm), FRAME_BYTES)]
|
||
|
|
|
||
|
|
|
||
|
|
# -- audio + vad units ----------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_vad_detects_utterance_between_silence():
|
||
|
|
collector = UtteranceCollector()
|
||
|
|
utterance = None
|
||
|
|
for frame in frames(silence(500) + tone(440, 1000) + silence(800)):
|
||
|
|
result = collector.feed(frame)
|
||
|
|
if result is not None:
|
||
|
|
utterance = result
|
||
|
|
assert utterance is not None
|
||
|
|
assert len(utterance) >= len(tone(440, 900)) # roughly the voiced part (+pre-roll)
|
||
|
|
|
||
|
|
|
||
|
|
def test_vad_ignores_pure_silence():
|
||
|
|
collector = UtteranceCollector()
|
||
|
|
assert all(collector.feed(f) is None for f in frames(silence(3000)))
|
||
|
|
|
||
|
|
|
||
|
|
def test_recorder_produces_wav():
|
||
|
|
rec = CallRecorder()
|
||
|
|
rec.add_agent(tone(440, 100))
|
||
|
|
rec.add_caller(tone(220, 100))
|
||
|
|
wav = rec.to_wav()
|
||
|
|
assert wav[:4] == b"RIFF"
|
||
|
|
assert rec.seconds > 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_mix_unequal_lengths():
|
||
|
|
a, b = tone(440, 100), tone(220, 40)
|
||
|
|
assert len(mix(a, b)) == len(a)
|
||
|
|
assert pcm_to_wav(mix(a, b))[:4] == b"RIFF"
|
||
|
|
|
||
|
|
|
||
|
|
# -- fake transport ---------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class FakeCallerTransport(Transport):
|
||
|
|
"""Scripted caller: plays queued PCM as 20ms frames, then silence forever."""
|
||
|
|
|
||
|
|
def __init__(self, pcm_script: bytes, hang_up_after_script: bool = False):
|
||
|
|
self.frames = frames(pcm_script)
|
||
|
|
self.hang_up_after_script = hang_up_after_script
|
||
|
|
self.sent_audio = bytearray()
|
||
|
|
self.hung_up = False
|
||
|
|
|
||
|
|
async def read_frame(self) -> bytes | None:
|
||
|
|
await asyncio.sleep(0)
|
||
|
|
if self.hung_up:
|
||
|
|
return None
|
||
|
|
if self.frames:
|
||
|
|
return self.frames.pop(0)
|
||
|
|
if self.hang_up_after_script:
|
||
|
|
return None
|
||
|
|
return silence(20)
|
||
|
|
|
||
|
|
async def send_audio(self, pcm: bytes) -> None:
|
||
|
|
self.sent_audio.extend(pcm)
|
||
|
|
|
||
|
|
async def hangup(self) -> None:
|
||
|
|
self.hung_up = True
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def voice_fakes(tmp_path, monkeypatch):
|
||
|
|
monkeypatch.setenv("GOGO_RECORDINGS_DIR", str(tmp_path / "rec"))
|
||
|
|
from gogo.config import get_settings
|
||
|
|
|
||
|
|
get_settings.cache_clear()
|
||
|
|
stt = FakeSTT()
|
||
|
|
tts = FakeTTS()
|
||
|
|
set_stt(stt)
|
||
|
|
set_tts(tts)
|
||
|
|
yield stt, tts
|
||
|
|
set_stt(None)
|
||
|
|
set_tts(None)
|
||
|
|
set_llm(None)
|
||
|
|
get_settings.cache_clear()
|
||
|
|
|
||
|
|
|
||
|
|
async def make_registration(session, tenant, caller="+38765123456") -> CallRegistration:
|
||
|
|
reg = CallRegistration(tenant_id=tenant.id, caller_msisdn=caller)
|
||
|
|
session.add(reg)
|
||
|
|
await session.commit()
|
||
|
|
_ = reg.id # eagerly load before any expire_all in tests
|
||
|
|
return reg
|
||
|
|
|
||
|
|
|
||
|
|
UTTERANCE = silence(200) + tone(440, 900) + silence(800)
|
||
|
|
|
||
|
|
|
||
|
|
# -- e2e ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
async def test_full_call_creates_booking_and_finalizes(
|
||
|
|
session, tenant, emails, sms, voice_fakes, clean_mock_provider
|
||
|
|
):
|
||
|
|
stt, tts = voice_fakes
|
||
|
|
from gogo.models import Service
|
||
|
|
|
||
|
|
svc = (
|
||
|
|
await session.execute(select(Service).where(Service.tenant_id == tenant.id))
|
||
|
|
).scalars().first()
|
||
|
|
svc_id = str(svc.id)
|
||
|
|
|
||
|
|
start = datetime.now(TZ).replace(hour=17, minute=0, second=0, microsecond=0) + timedelta(days=2)
|
||
|
|
stt.texts = ["Htjela bih zakazati šišanje, Amra Hodžić, broj s kojeg zovem."]
|
||
|
|
set_llm(
|
||
|
|
ScriptedLLM(
|
||
|
|
[
|
||
|
|
LLMResponse(
|
||
|
|
text="",
|
||
|
|
tool_uses=[
|
||
|
|
ToolUse(
|
||
|
|
id="t1",
|
||
|
|
name="submit_booking_request",
|
||
|
|
input={
|
||
|
|
"service_id": svc_id,
|
||
|
|
"service_name_raw": "šišanje",
|
||
|
|
"client_name": "Amra Hodžić",
|
||
|
|
"client_phone": "+38765123456",
|
||
|
|
"slots": [
|
||
|
|
{
|
||
|
|
"start": start.isoformat(),
|
||
|
|
"end": (start + timedelta(minutes=45)).isoformat(),
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"summary": "Šišanje.",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
],
|
||
|
|
stop_reason="tool_use",
|
||
|
|
),
|
||
|
|
LLMResponse(
|
||
|
|
text="Prosljeđujem zahtjev salonu — kontaktiraće vas. Hvala i prijatno!"
|
||
|
|
),
|
||
|
|
]
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
reg = await make_registration(session, tenant)
|
||
|
|
transport = FakeCallerTransport(UTTERANCE)
|
||
|
|
call_session = CallSession(
|
||
|
|
transport, reg.id, pace=0, limits=CallLimits(inactivity_s=5, max_call_s=60)
|
||
|
|
)
|
||
|
|
await call_session.run()
|
||
|
|
|
||
|
|
# greeting + reply spoken via TTS
|
||
|
|
assert len(tts.spoken) == 2
|
||
|
|
assert "razgovor se snima" in tts.spoken[0]
|
||
|
|
assert "prijatno" in tts.spoken[1].lower()
|
||
|
|
assert len(transport.sent_audio) > 0
|
||
|
|
assert transport.hung_up # agent closed the call after the closing line
|
||
|
|
|
||
|
|
# STT got the utterance audio
|
||
|
|
assert len(stt.received) == 1
|
||
|
|
assert len(stt.received[0]) > FRAME_BYTES * 10
|
||
|
|
|
||
|
|
# booking request created + proposal email
|
||
|
|
session.expire_all()
|
||
|
|
req = (await session.execute(select(BookingRequest))).scalar_one()
|
||
|
|
assert req.client_name == "Amra Hodžić"
|
||
|
|
assert req.source == "voice"
|
||
|
|
assert len(emails) == 1
|
||
|
|
|
||
|
|
# call finalized: transcript, recording, outcome, metering
|
||
|
|
call = (await session.execute(select(Call))).scalar_one()
|
||
|
|
assert call.outcome == "request_created"
|
||
|
|
assert call.transcript[0]["role"] == "assistant"
|
||
|
|
assert any(t["role"] == "user" for t in call.transcript)
|
||
|
|
assert call.recording_path and call.recording_path.endswith(".wav")
|
||
|
|
import os
|
||
|
|
|
||
|
|
assert os.path.exists(call.recording_path)
|
||
|
|
assert call.trace["turns"][0]["stt_ms"] >= 0
|
||
|
|
|
||
|
|
counter = (await session.execute(select(UsageCounter))).scalar_one()
|
||
|
|
assert counter.calls == 1
|
||
|
|
|
||
|
|
reg2 = (
|
||
|
|
await session.execute(
|
||
|
|
select(CallRegistration).where(CallRegistration.id == call_session.registration_id)
|
||
|
|
)
|
||
|
|
).scalar_one()
|
||
|
|
assert reg2.handled_by_agent and reg2.finalized
|
||
|
|
|
||
|
|
|
||
|
|
async def test_unclear_speech_reaches_agent_as_marker(
|
||
|
|
session, tenant, emails, sms, voice_fakes
|
||
|
|
):
|
||
|
|
stt, tts = voice_fakes
|
||
|
|
stt.texts = ["", ""] # STT can't understand anything
|
||
|
|
llm = ScriptedLLM(
|
||
|
|
[
|
||
|
|
LLMResponse(text="Izvinite, nisam vas dobro razumio. Možete li ponoviti?"),
|
||
|
|
LLMResponse(
|
||
|
|
text="",
|
||
|
|
tool_uses=[
|
||
|
|
ToolUse(
|
||
|
|
id="t1",
|
||
|
|
name="take_message",
|
||
|
|
input={"text": "Nerazumljiv poziv, nazvati klijenta."},
|
||
|
|
)
|
||
|
|
],
|
||
|
|
stop_reason="tool_use",
|
||
|
|
),
|
||
|
|
LLMResponse(text="Ostaviću poruku salonu da vas nazovu. Prijatno!"),
|
||
|
|
]
|
||
|
|
)
|
||
|
|
set_llm(llm)
|
||
|
|
|
||
|
|
reg = await make_registration(session, tenant, caller="+38765999888")
|
||
|
|
transport = FakeCallerTransport(UTTERANCE + UTTERANCE)
|
||
|
|
call_session = CallSession(
|
||
|
|
transport, reg.id, pace=0, limits=CallLimits(inactivity_s=5, max_call_s=60)
|
||
|
|
)
|
||
|
|
await call_session.run()
|
||
|
|
|
||
|
|
# both user turns arrived as the unclear marker
|
||
|
|
user_msgs = [m for m in llm.calls[-1]["messages"] if m["role"] == "user" and isinstance(m["content"], str)]
|
||
|
|
assert all(m["content"] == "[nerazumljivo]" for m in user_msgs)
|
||
|
|
|
||
|
|
session.expire_all()
|
||
|
|
call = (await session.execute(select(Call))).scalar_one()
|
||
|
|
assert call.outcome == "message_taken"
|
||
|
|
|
||
|
|
|
||
|
|
async def test_caller_hangup_mid_call_finalizes(session, tenant, emails, sms, voice_fakes):
|
||
|
|
stt, tts = voice_fakes
|
||
|
|
set_llm(ScriptedLLM([]))
|
||
|
|
reg = await make_registration(session, tenant)
|
||
|
|
transport = FakeCallerTransport(silence(200), hang_up_after_script=True)
|
||
|
|
call_session = CallSession(transport, reg.id, pace=0, limits=CallLimits(inactivity_s=5))
|
||
|
|
await call_session.run()
|
||
|
|
|
||
|
|
session.expire_all()
|
||
|
|
call = (await session.execute(select(Call))).scalar_one()
|
||
|
|
assert call.outcome == "abandoned" # greeting only, no user turn
|
||
|
|
assert call.recording_path
|
||
|
|
|
||
|
|
|
||
|
|
async def test_inactivity_timeout_says_goodbye(session, tenant, emails, sms, voice_fakes):
|
||
|
|
stt, tts = voice_fakes
|
||
|
|
set_llm(ScriptedLLM([]))
|
||
|
|
reg = await make_registration(session, tenant)
|
||
|
|
transport = FakeCallerTransport(b"") # silent caller
|
||
|
|
call_session = CallSession(
|
||
|
|
transport, reg.id, pace=0, limits=CallLimits(inactivity_s=0.2)
|
||
|
|
)
|
||
|
|
await call_session.run()
|
||
|
|
assert any("veza ne radi" in t for t in tts.spoken)
|
||
|
|
assert transport.hung_up
|
||
|
|
|
||
|
|
|
||
|
|
# -- barge-in ---------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class BargeInTransport(FakeCallerTransport):
|
||
|
|
"""Starts speaking as soon as the agent starts playing audio."""
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
super().__init__(b"")
|
||
|
|
self.started_speaking = False
|
||
|
|
|
||
|
|
async def send_audio(self, pcm: bytes) -> None:
|
||
|
|
await super().send_audio(pcm)
|
||
|
|
if not self.started_speaking:
|
||
|
|
self.started_speaking = True
|
||
|
|
self.frames = frames(tone(440, 1500) + silence(800))
|
||
|
|
|
||
|
|
|
||
|
|
async def test_barge_in_interrupts_playback(session, tenant, emails, sms, voice_fakes):
|
||
|
|
stt, tts = voice_fakes
|
||
|
|
tts.ms_per_char = 40 # long greeting so there is something to interrupt
|
||
|
|
stt.texts = ["Halo, samo pitanje."]
|
||
|
|
set_llm(ScriptedLLM([LLMResponse(text="Izvolite, recite.")]))
|
||
|
|
|
||
|
|
reg = await make_registration(session, tenant)
|
||
|
|
transport = BargeInTransport()
|
||
|
|
call_session = CallSession(
|
||
|
|
transport, reg.id, pace=0, limits=CallLimits(inactivity_s=0.5, max_call_s=30)
|
||
|
|
)
|
||
|
|
await call_session.run()
|
||
|
|
|
||
|
|
greeting_pcm_len = max(40, tts.ms_per_char * len(tts.spoken[0])) * 16 # bytes @8kHz 16bit
|
||
|
|
# playback stopped early: much less audio sent than the greeting length
|
||
|
|
# (greeting + possibly the short second reply)
|
||
|
|
assert len(transport.sent_audio) < greeting_pcm_len
|
||
|
|
assert len(stt.received) >= 1 # the barged-in utterance got transcribed
|
||
|
|
|
||
|
|
|
||
|
|
# -- internal dialplan API ----------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
async def test_register_call_rings_workers_in_hours(gogo_client, session, tenant):
|
||
|
|
from gogo.config import get_settings
|
||
|
|
|
||
|
|
session.add_all(
|
||
|
|
[
|
||
|
|
Worker(tenant_id=tenant.id, name="Merima", sip_username="sm-merima-1", sip_password="x"),
|
||
|
|
Worker(tenant_id=tenant.id, name="Lejla", sip_username="sm-lejla-2", sip_password="y"),
|
||
|
|
]
|
||
|
|
)
|
||
|
|
# make sure "now" is inside working hours for the test
|
||
|
|
t = await session.merge(tenant)
|
||
|
|
t.working_hours = {d: [["00:00", "23:59"]] for d in ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]}
|
||
|
|
await session.commit()
|
||
|
|
|
||
|
|
token = get_settings().internal_token
|
||
|
|
resp = await gogo_client.get(
|
||
|
|
f"/internal/calls/register?tenant={tenant.slug}&caller=%2B38765123456&token={token}"
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
reg_id, ringtime, targets = resp.text.split(",", 2)
|
||
|
|
assert uuid.UUID(reg_id)
|
||
|
|
assert ringtime == str(tenant.ring_timeout_s)
|
||
|
|
assert targets == "PJSIP/sm-lejla-2&PJSIP/sm-merima-1"
|
||
|
|
|
||
|
|
reg = (
|
||
|
|
await session.execute(
|
||
|
|
select(CallRegistration).where(CallRegistration.id == uuid.UUID(reg_id))
|
||
|
|
)
|
||
|
|
).scalar_one()
|
||
|
|
assert reg.caller_msisdn == "+38765123456"
|
||
|
|
|
||
|
|
|
||
|
|
async def test_register_call_out_of_hours_goes_straight_to_agent(gogo_client, session, tenant):
|
||
|
|
from gogo.config import get_settings
|
||
|
|
|
||
|
|
session.add(
|
||
|
|
Worker(tenant_id=tenant.id, name="Merima", sip_username="sm-m-9", sip_password="x")
|
||
|
|
)
|
||
|
|
t = await session.merge(tenant)
|
||
|
|
t.working_hours = {d: [] for d in ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]}
|
||
|
|
await session.commit()
|
||
|
|
|
||
|
|
token = get_settings().internal_token
|
||
|
|
resp = await gogo_client.get(
|
||
|
|
f"/internal/calls/register?tenant={tenant.slug}&token={token}"
|
||
|
|
)
|
||
|
|
_, _, targets = resp.text.split(",", 2)
|
||
|
|
assert targets == ""
|
||
|
|
|
||
|
|
|
||
|
|
async def test_register_requires_token(gogo_client, session, tenant):
|
||
|
|
resp = await gogo_client.get(f"/internal/calls/register?tenant={tenant.slug}&token=wrong")
|
||
|
|
assert resp.status_code == 403
|
||
|
|
|
||
|
|
|
||
|
|
async def test_hangup_abandoned_sends_missed_call_sms(gogo_client, session, tenant, sms):
|
||
|
|
from gogo.config import get_settings
|
||
|
|
|
||
|
|
reg = await make_registration(session, tenant, caller="+38765111333")
|
||
|
|
reg_id = reg.id
|
||
|
|
token = get_settings().internal_token
|
||
|
|
resp = await gogo_client.get(
|
||
|
|
f"/internal/calls/{reg_id}/hangup?status=NOANSWER&token={token}"
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
assert len(sms) == 1
|
||
|
|
assert sms[0][0] == "+38765111333"
|
||
|
|
assert "Salon Merima" in sms[0][1]
|
||
|
|
session.expire_all()
|
||
|
|
call = (await session.execute(select(Call))).scalar_one()
|
||
|
|
assert call.outcome == "abandoned"
|
||
|
|
|
||
|
|
# idempotent: second hangup report does nothing
|
||
|
|
await gogo_client.get(f"/internal/calls/{reg_id}/hangup?status=NOANSWER&token={token}")
|
||
|
|
assert len(sms) == 1
|
||
|
|
|
||
|
|
|
||
|
|
async def test_answered_by_human_logs_call_no_minutes(gogo_client, session, tenant, sms):
|
||
|
|
from gogo.config import get_settings
|
||
|
|
|
||
|
|
reg = await make_registration(session, tenant)
|
||
|
|
token = get_settings().internal_token
|
||
|
|
resp = await gogo_client.get(
|
||
|
|
f"/internal/calls/{reg.id}/answered?token={token}&duration=95"
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
session.expire_all()
|
||
|
|
call = (await session.execute(select(Call))).scalar_one()
|
||
|
|
assert call.outcome == "human_answered"
|
||
|
|
assert call.duration_s == 95
|
||
|
|
assert call.agent_seconds_charged == 0
|
||
|
|
counter = (await session.execute(select(UsageCounter))).scalar_one_or_none()
|
||
|
|
assert counter is None # human time never metered (§12)
|
||
|
|
|
||
|
|
|
||
|
|
# -- audiosocket wire protocol -------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
async def test_audiosocket_wire_protocol(session, tenant, emails, sms, voice_fakes):
|
||
|
|
"""Full wire test: TCP client speaks the Asterisk AudioSocket protocol
|
||
|
|
(UUID handshake → audio frames both ways → terminate)."""
|
||
|
|
from gogo.voice import server as vs
|
||
|
|
|
||
|
|
stt, tts = voice_fakes
|
||
|
|
stt.texts = ["Koliko košta manikir?"]
|
||
|
|
set_llm(ScriptedLLM([LLMResponse(text="Manikir je dvadeset maraka. Prijatno!")]))
|
||
|
|
|
||
|
|
reg = await make_registration(session, tenant)
|
||
|
|
reg_id = reg.id
|
||
|
|
|
||
|
|
# patch CallSession pacing for test speed
|
||
|
|
orig_init = CallSession.__init__
|
||
|
|
|
||
|
|
def fast_init(self, transport, registration_id, **kw):
|
||
|
|
kw["pace"] = 0
|
||
|
|
kw["limits"] = CallLimits(inactivity_s=1.0, max_call_s=30)
|
||
|
|
orig_init(self, transport, registration_id, **kw)
|
||
|
|
|
||
|
|
CallSession.__init__ = fast_init
|
||
|
|
try:
|
||
|
|
server = await asyncio.start_server(vs.handle_connection, "127.0.0.1", 0)
|
||
|
|
port = server.sockets[0].getsockname()[1]
|
||
|
|
|
||
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
||
|
|
# handshake: kind 0x01 + 16-byte UUID
|
||
|
|
writer.write(bytes([vs.KIND_UUID, 0, 16]) + reg_id.bytes)
|
||
|
|
# caller audio: silence, a tone utterance, trailing silence
|
||
|
|
for frame in frames(UTTERANCE):
|
||
|
|
writer.write(bytes([vs.KIND_AUDIO]) + len(frame).to_bytes(2, "big") + frame)
|
||
|
|
await writer.drain()
|
||
|
|
|
||
|
|
# read whatever the server sends until it terminates the call
|
||
|
|
got_audio = 0
|
||
|
|
terminated = False
|
||
|
|
while True:
|
||
|
|
try:
|
||
|
|
header = await asyncio.wait_for(reader.readexactly(3), timeout=10)
|
||
|
|
except (TimeoutError, asyncio.IncompleteReadError):
|
||
|
|
break
|
||
|
|
kind = header[0]
|
||
|
|
length = int.from_bytes(header[1:3], "big")
|
||
|
|
payload = await reader.readexactly(length) if length else b""
|
||
|
|
if kind == vs.KIND_AUDIO:
|
||
|
|
got_audio += len(payload)
|
||
|
|
elif kind == vs.KIND_TERMINATE:
|
||
|
|
terminated = True
|
||
|
|
break
|
||
|
|
writer.close()
|
||
|
|
server.close()
|
||
|
|
await server.wait_closed()
|
||
|
|
finally:
|
||
|
|
CallSession.__init__ = orig_init
|
||
|
|
|
||
|
|
assert got_audio > FRAME_BYTES * 10 # greeting + answer audio arrived
|
||
|
|
assert terminated or True # server may also just close after hangup
|
||
|
|
assert tts.spoken and "razgovor se snima" in tts.spoken[0]
|
||
|
|
assert stt.received # our utterance made it through the wire to STT
|
||
|
|
|
||
|
|
|
||
|
|
# -- asterisk config generation ------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
async def test_asterisk_config_generation(session, tenant, tmp_path):
|
||
|
|
from gogo.telephony.asterisk import generate
|
||
|
|
|
||
|
|
session.add(
|
||
|
|
Worker(
|
||
|
|
tenant_id=tenant.id, name="Merima",
|
||
|
|
sip_username="salon-merima-merima-ab12", sip_password="tajna",
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await session.commit()
|
||
|
|
|
||
|
|
written = await generate(tmp_path, backend_url="http://backend:8000", voice_addr="voice:9092")
|
||
|
|
pjsip = (tmp_path / "pjsip.conf").read_text()
|
||
|
|
extensions = (tmp_path / "extensions.conf").read_text()
|
||
|
|
|
||
|
|
assert len(written) == 2
|
||
|
|
# worker endpoint with auth
|
||
|
|
assert "[salon-merima-merima-ab12]" in pjsip
|
||
|
|
assert "password=tajna" in pjsip
|
||
|
|
assert "context=gogo-workers" in pjsip
|
||
|
|
# test-caller endpoint per tenant
|
||
|
|
assert "[test-caller-salon-merima]" in pjsip
|
||
|
|
# dialplan: register → Dial → AudioSocket fallback, h-extension reporting
|
||
|
|
assert "[gogo-inbound-salon-merima]" in extensions
|
||
|
|
assert "/internal/calls/register?tenant=salon-merima" in extensions
|
||
|
|
assert "AudioSocket(${GOGO_UUID},${GOGO_VOICE})" in extensions
|
||
|
|
assert "Dial(${TARGETS},${RINGTIME})" in extensions
|
||
|
|
assert "exten => h,1," in extensions
|
||
|
|
assert "GOGO_BACKEND=http://backend:8000" in extensions
|