diff --git a/README.md b/README.md index 41113ed..ebae40c 100644 --- a/README.md +++ b/README.md @@ -541,20 +541,25 @@ credentials: ```python import asyncio import logging -import os from whatsapp_chatbot_python.calls import ( CallEvent, CallSession, CallStateMachine, ) from whatsapp_chatbot_python.calls.service import WhatsAppCallService +id_instance = "ID_INSTANCE" +api_token_instance = "API_TOKEN" +openai_api_key = "OPENAI_KEY" +realtime_model = "gpt-realtime-2.1" +realtime_voice = "marin" + service = WhatsAppCallService( api_url="https://api.green-api.com", - id_instance=os.environ["ID_INSTANCE"], - api_token_instance=os.environ["API_TOKEN_INSTANCE"], - openai_api_key=os.environ["OPENAI_API_KEY"], - realtime_model=os.environ["REALTIME_MODEL"], - realtime_voice=os.environ["REALTIME_VOICE"], + id_instance=id_instance, + api_token_instance=api_token_instance, + openai_api_key=openai_api_key, + realtime_model=realtime_model, + realtime_voice=realtime_voice, ring_timeout_seconds=30, talk_timeout_seconds=120, shutdown_timeout_seconds=10, @@ -571,18 +576,55 @@ async def call_once(): fsm.apply(session, CallEvent.ENQUEUED) fsm.apply(session, CallEvent.DEQUEUED) result = await service.execute(session, fsm.apply) - try: + with result: print(session.state, session.end_reason, session.error_code) if result.recording_path is not None: print("MP3:", result.recording_path) - # Upload or copy the recording here, before removing it. - finally: - if result.recording_path is not None: - result.recording_path.unlink(missing_ok=True) + # Upload or copy it here; the context removes the temporary file. asyncio.run(call_once()) ``` +`with result:` removes an unclaimed MP3 after use. Call `result.take_recording()` +to keep the file. `CallOptions(input_audio=InputAudioOptions(...))` configures the +bounded queue from RTC audio to the voice model; a full queue fails the call. +Cleanup timeouts mean resource closure is unconfirmed, not complete. The service +then reports `healthy=False` and rejects subsequent calls. The installed RTC SDK +can leave signaling open if its own close operation never finishes; restart the +worker after investigating such a failure. + +### Accept an incoming call + +The application owns its queue, offer deduplication and notification parsing. An incoming offer can share the same queue +and worker as outgoing calls. Create its deadline when the offer arrives, using the time remaining from the offer +timestamp. For example, a 60-second application limit becomes `monotonic() + remaining_seconds`; the library has no +fixed incoming ring limit. The deadline includes time spent in the queue and in call setup. Coordinators can type their +executor as `IncomingCallExecutor`, which extends the existing `CallExecutor` with the two incoming call-control +methods. + +```python +from time import monotonic +from whatsapp_chatbot_python.calls import CallEvent, CallSession, CallStateMachine + +session = CallSession.incoming( + sender_id=caller_id, + call_id=call_id, + language="en", + incoming_deadline=monotonic() + remaining_seconds, + greeting_text="Hello! This is a WhatsApp demo call. What would you like to discuss?", +) +fsm = CallStateMachine() +fsm.apply(session, CallEvent.ENQUEUED) +app_queue.put(session) + +if monotonic() >= session.incoming_deadline: + fsm.apply(session, CallEvent.INCOMING_EXPIRED) + await service.reject_incoming(session.call_id, session.sender_id) +else: + fsm.apply(session, CallEvent.DEQUEUED) + result = await service.execute(session, transition_under_app_lock) +``` + ## Service methods documentation [Service methods documentation](https://green-api.com/en/docs/api/) diff --git a/docs/README.md b/docs/README.md index 139425c..1b1a403 100644 --- a/docs/README.md +++ b/docs/README.md @@ -533,20 +533,25 @@ python -m pip install 'whatsapp-chatbot-python[voip]' ```python import asyncio import logging -import os from whatsapp_chatbot_python.calls import ( CallEvent, CallSession, CallStateMachine, ) from whatsapp_chatbot_python.calls.service import WhatsAppCallService +id_instance = "ID_INSTANCE" +api_token_instance = "API_TOKEN" +openai_api_key = "OPENAI_KEY" +realtime_model = "gpt-realtime-2.1" +realtime_voice = "marin" + service = WhatsAppCallService( api_url="https://api.green-api.com", - id_instance=os.environ["ID_INSTANCE"], - api_token_instance=os.environ["API_TOKEN_INSTANCE"], - openai_api_key=os.environ["OPENAI_API_KEY"], - realtime_model=os.environ["REALTIME_MODEL"], - realtime_voice=os.environ["REALTIME_VOICE"], + id_instance=id_instance, + api_token_instance=api_token_instance, + openai_api_key=openai_api_key, + realtime_model=realtime_model, + realtime_voice=realtime_voice, ring_timeout_seconds=30, talk_timeout_seconds=120, shutdown_timeout_seconds=10, @@ -563,18 +568,55 @@ async def call_once(): fsm.apply(session, CallEvent.ENQUEUED) fsm.apply(session, CallEvent.DEQUEUED) result = await service.execute(session, fsm.apply) - try: + with result: print(session.state, session.end_reason, session.error_code) if result.recording_path is not None: print("MP3:", result.recording_path) - # Upload or copy the recording here, before removing it. - finally: - if result.recording_path is not None: - result.recording_path.unlink(missing_ok=True) asyncio.run(call_once()) ``` +`with result:` удаляет невостребованный MP3 после использования. Метод +`result.take_recording()` передаёт владение файлом приложению. Настройка +`CallOptions(input_audio=InputAudioOptions(...))` ограничивает очередь аудио +между RTC и голосовой моделью; переполнение завершает звонок. Таймаут очистки +означает неподтверждённое закрытие ресурса. Сервис выставляет `healthy=False` +и не принимает следующие звонки. Если закрытие сигнального соединения внутри +установленного RTC SDK так и не завершилось, после проверки нужен перезапуск +рабочего процесса. + +### Принять входящий звонок + +Очередь, дедупликация предложений звонка и разбор уведомлений принадлежат приложению. Входящий звонок может использовать +того же работника очереди, что и исходящий. При получении `offer` приложение вычисляет оставшееся время по времени +уведомления и передаёт абсолютный срок `monotonic() + remaining_seconds`. Например, лимит 60 секунд задаёт приложение; +библиотека не содержит фиксированного лимита входящего дозвона. Срок включает ожидание в очереди и подготовку вызова. +Координатор может использовать тип `IncomingCallExecutor`: он расширяет существующий `CallExecutor` двумя методами +управления входящим вызовом. + +```python +from time import monotonic +from whatsapp_chatbot_python.calls import CallEvent, CallSession, CallStateMachine + +session = CallSession.incoming( + sender_id=caller_id, + call_id=call_id, + language="ru", + incoming_deadline=monotonic() + remaining_seconds, + greeting_text="Здравствуйте! Это демонстрационный звонок WhatsApp. Что хотите обсудить?", +) +fsm = CallStateMachine() +fsm.apply(session, CallEvent.ENQUEUED) +app_queue.put(session) + +if monotonic() >= session.incoming_deadline: + fsm.apply(session, CallEvent.INCOMING_EXPIRED) + await service.reject_incoming(session.call_id, session.sender_id) +else: + fsm.apply(session, CallEvent.DEQUEUED) + result = await service.execute(session, transition_under_app_lock) +``` + ## Документация по методам сервиса [Документация по методам сервиса](https://green-api.com/docs/api/) diff --git a/examples/call.py b/examples/call.py index dfc1664..d42142c 100644 --- a/examples/call.py +++ b/examples/call.py @@ -1,4 +1,4 @@ -from whatsapp_chatbot_python import GreenAPIBot, Notification +from whatsapp_chatbot_python import GreenAPIBot, IncomingCallNotification bot = GreenAPIBot( "1101000001", "d75b3a66374942c5b3c019c698abc2067e151558acbd412345" @@ -6,7 +6,9 @@ @bot.router.incoming_call() -def message_handler(notification: Notification) -> None: - print("Call from:", notification.event["from"], "with status", notification.event["status"]) +def incoming_call_handler(notification: IncomingCallNotification) -> None: + call = notification.call + print("Call from:", call.sender, "with status", call.status) + # notification.event (or notification.raw_event) still contains the original webhook. bot.run_forever() diff --git a/ruff.toml b/ruff.toml index 46b0b15..03a78ee 100644 --- a/ruff.toml +++ b/ruff.toml @@ -2,5 +2,8 @@ target-version = "py39" force-exclude = true +[per-file-target-version] +"whatsapp_chatbot_python/calls/*.py" = "py311" + [lint] select = ["E4", "E7", "E9", "F"] diff --git a/setup.py b/setup.py index 057f4e5..6bb9242 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="whatsapp-chatbot-python", - version="1.0.0", + version="1.1.0", description=( "This library helps you easily create" " a Python chatbot with WhatsApp API." diff --git a/tests/calls/conftest.py b/tests/calls/conftest.py index 492217b..82016aa 100644 --- a/tests/calls/conftest.py +++ b/tests/calls/conftest.py @@ -8,5 +8,6 @@ collect_ignore_glob = ["test_*.py"] elif any(find_spec(name) is None for name in ("openai", "aiortc", "av")): collect_ignore_glob = [ - "test_call_service.py", "test_realtime_voice.py", "test_call_recording.py" + "test_call_service.py", "test_call_cleanup.py", + "test_realtime_voice.py", "test_call_recording.py", "test_audio.py" ] diff --git a/tests/calls/fakes.py b/tests/calls/fakes.py new file mode 100644 index 0000000..39d0a86 --- /dev/null +++ b/tests/calls/fakes.py @@ -0,0 +1,162 @@ +"""Deterministic external boundaries for call integration specifications.""" + +import asyncio +from dataclasses import dataclass, field +from types import SimpleNamespace + +from whatsapp_chatbot_python.calls.models import PCM + + +@dataclass +class Scenario: + state: str = "idle" + call_id: str = "call-1" + wid: str = "sender@c.us" + answer: bool = True + remote_hangup: bool = True + accept_error: bool = False + open_error: bool = False + bridge_error: bool = False + voice_error: bool = False + gates: dict[str, asyncio.Event] = field(default_factory=dict) + actions: list = field(default_factory=list) + + def info(self): + return SimpleNamespace(id=self.call_id, wid=self.wid) + + async def wait(self, name): + if gate := self.gates.get(name): + await gate.wait() + + +class FakeRtc: + def __init__(self, api, audio_factory): + self.api = api + self.audio_factory = audio_factory + self.listeners = {} + self.audio = None + self.closed = False + + def on(self, name, callback): + self.listeners[name] = callback + + def emit(self, name, detail=None): + if name == "state": + self.api.state = detail.state + self.api.reason = getattr(detail, "reason", None) + callback = self.listeners.get(name) + if callback: + callback(detail) + + def set_state(self, state, reason=None, *, call_id=None): + info = None if state == "idle" else SimpleNamespace( + id=call_id or self.api.scenario.call_id, wid=self.api.scenario.wid) + self.emit("state", SimpleNamespace(state=state, reason=reason, info=info)) + + async def openAsync(self, *, timeout): + self.api.scenario.actions.append("open") + await self.api.scenario.wait("open") + if self.api.scenario.open_error: + raise RuntimeError("RTC unavailable") + self.set_state(self.api.state) + + async def startAudioAsync(self): + self.api.scenario.actions.append("bridge") + await self.api.scenario.wait("bridge") + self.audio = await self.audio_factory() + if self.api.scenario.bridge_error: + raise RuntimeError("bridge unavailable") + if self.api.scenario.remote_hangup: + asyncio.get_running_loop().call_later( + 0.01, self.set_state, "idle", "hangup") + + async def closeAsync(self): + self.api.scenario.actions.append("rtc_close") + await self.api.scenario.wait("rtc_close") + if self.audio is not None: + await self.audio.close() + self.closed = True + + +class FakeApi: + def __init__(self, scenario, id_instance, api_token_instance, *, host, raise_errors, host_timeout): + self.scenario = scenario + self.credentials = (id_instance, api_token_instance, host) + self.state = scenario.state + self.reason = None + self.rtc = None + self.session_closed = False + self.session = SimpleNamespace(close=self.close_session) + self.voip = self + + def close_session(self): + self.scenario.actions.append("session_close") + self.session_closed = True + + def connect(self, *, audio_factory): + self.rtc = FakeRtc(self, audio_factory) + return self.rtc + + async def getStateAsync(self): + self.scenario.actions.append("get_state") + info = None if self.state == "idle" else self.scenario.info() + return SimpleNamespace(state=self.state, info=info, reason=self.reason) + + async def dialAsync(self, target): + self.scenario.actions.append(("dial", target)) + await self.scenario.wait("dial") + self.rtc.set_state("out-call") + if self.scenario.answer: + asyncio.get_running_loop().call_soon(self.rtc.set_state, "on-call") + + async def acceptAsync(self): + self.scenario.actions.append("accept") + await self.scenario.wait("accept") + if self.scenario.accept_error: + raise RuntimeError("accept unavailable") + self.rtc.set_state("on-call") + + async def rejectAsync(self): + self.scenario.actions.append("reject") + self.state = "idle" + + async def hangUpAsync(self): + self.scenario.actions.append("hangup") + self.state = "idle" + + +class FakeVoice: + def __init__(self, scenario, *args): + self.scenario = scenario + self.greetings = [] + self.closed = False + self.started = False + + async def __aenter__(self): + self.scenario.actions.append("voice_enter") + return self + + async def __aexit__(self, *exc): + self.scenario.actions.append("voice_close") + self.closed = True + + async def start(self): + self.scenario.actions.append("voice_start") + await self.scenario.wait("voice_start") + if self.scenario.voice_error: + raise RuntimeError("voice unavailable") + self.started = True + + async def greet_once(self, text): + if not self.greetings: + self.greetings.append(text) + self.scenario.actions.append("greet") + + def read_frame(self): + return bytes(PCM.bytes_per_frame) + + def replace_track(self): + self.scenario.actions.append("replace_track") + + async def write_audio(self, data): + self.scenario.actions.append(("caller_audio", len(data))) diff --git a/tests/calls/test_audio.py b/tests/calls/test_audio.py new file mode 100644 index 0000000..5d90212 --- /dev/null +++ b/tests/calls/test_audio.py @@ -0,0 +1,152 @@ +"""RTC audio frames, replacement and narrow PCM contracts.""" + +import asyncio +import unittest +from fractions import Fraction + +import av + +from whatsapp_chatbot_python.calls.audio import AudioBridge, BotTrack, CallerSink, RecordingTap +from whatsapp_chatbot_python.calls.models import InputAudioOptions, MediaFailed, PCM +from whatsapp_chatbot_python.calls.recorder import Track + + +FRAME = bytes([3, 4]) * PCM.samples_per_frame + + +class Source: + def __init__(self): + self.replacements = 0 + self.frames = 0 + + def read_frame(self): + self.frames += 1 + return FRAME + + def replace_track(self): + self.replacements += 1 + + async def write_audio(self, data): + pass + + +class Capture: + def __init__(self): + self.chunks = [] + + def write(self, track, chunk): + self.chunks.append((track, chunk)) + + def disable(self, error): + raise AssertionError(error) + + +class AudioBoundaryTest(unittest.IsolatedAsyncioTestCase): + async def test_sender_failure_is_reported_without_another_rtc_frame(self): + class OneFrameTrack: + def __init__(self): + self.received = 0 + + async def recv(self): + self.received += 1 + if self.received > 1: + await asyncio.Event().wait() + + frame = av.AudioFrame(format="s16", layout="mono", samples=PCM.samples_per_frame) + frame.planes[0].update(FRAME) + frame.sample_rate = PCM.rate + return frame + + class BlockedSink: + async def write_audio(self, data): + await asyncio.Event().wait() + + failures = [] + track = OneFrameTrack() + caller = CallerSink( + BlockedSink(), RecordingTap(Capture()), failures.append, + InputAudioOptions(buffer_seconds=0.5, send_seconds=0.02), + ) + await caller.attach(track) + + for _ in range(100): + if failures: + break + await asyncio.sleep(0.001) + + self.assertEqual(track.received, 2) + self.assertEqual(len(failures), 1) + self.assertIsInstance(failures[0].error, TimeoutError) + await caller.close() + + async def test_slow_model_send_overloads_bounded_input_queue(self): + class FastTrack: + def __init__(self): + self.received = 0 + + async def recv(self): + self.received += 1 + frame = av.AudioFrame(format="s16", layout="mono", samples=PCM.samples_per_frame) + frame.planes[0].update(FRAME) + frame.sample_rate = PCM.rate + return frame + + class SlowSink: + async def write_audio(self, data): + await asyncio.Event().wait() + + track = FastTrack() + caller = CallerSink( + SlowSink(), RecordingTap(Capture()), lambda event: None, + InputAudioOptions(buffer_seconds=PCM.frame_seconds, send_seconds=0.1), + ) + + with self.assertRaisesRegex(BufferError, "Inbound audio buffer"): + await asyncio.wait_for(caller._consume(track), 0.5) + + self.assertLessEqual(track.received, 3) + + async def test_bot_frames_have_20ms_pcm_timestamps_and_are_recorded(self): + source, capture, events = Source(), Capture(), [] + tap = RecordingTap(capture) + tap.activate() + track = BotTrack(source, tap, events.append) + try: + first, second = await track.recv(), await track.recv() + self.assertEqual((first.pts, second.pts), (0, PCM.samples_per_frame)) + self.assertEqual((first.sample_rate, first.time_base), + (PCM.rate, Fraction(1, PCM.rate))) + self.assertEqual(bytes(first.planes[0])[:PCM.bytes_per_frame], FRAME) + self.assertEqual([side for side, _ in capture.chunks], [Track.BOT, Track.BOT]) + self.assertEqual(capture.chunks[1][1].position - capture.chunks[0][1].position, + PCM.samples_per_frame) + self.assertEqual(events, []) + finally: + track.stop() + + async def test_reconnect_replaces_track_and_stops_old_audio(self): + source, capture = Source(), Capture() + bridge = AudioBridge(source, source, capture, lambda event: None) + first = await bridge.create() + self.assertEqual(first.local_track.readyState, "live") + second = await bridge.create() + self.assertEqual(first.local_track.readyState, "ended") + self.assertEqual(second.local_track.readyState, "live") + self.assertEqual(source.replacements, 2) + await bridge.close() + self.assertEqual(second.local_track.readyState, "ended") + + async def test_bad_source_frame_surfaces_media_failure(self): + class BadSource(Source): + def read_frame(self): + return b"too short" + + events = [] + track = BotTrack(BadSource(), RecordingTap(Capture()), events.append) + try: + with self.assertRaisesRegex(ValueError, "exactly one PCM frame"): + await track.recv() + self.assertEqual(len(events), 1) + self.assertIsInstance(events[0], MediaFailed) + finally: + track.stop() diff --git a/tests/calls/test_call_cleanup.py b/tests/calls/test_call_cleanup.py index 50afb79..6a4e826 100644 --- a/tests/calls/test_call_cleanup.py +++ b/tests/calls/test_call_cleanup.py @@ -1,315 +1,219 @@ +"""Cancellation and resource release across setup, signaling and teardown.""" + import asyncio import unittest +from time import monotonic from unittest.mock import patch -from whatsapp_chatbot_python.calls.models import CallEndReason, CallEvent, CallState -from whatsapp_chatbot_python.calls.recording import CallRecorder - -from . import test_call_service as fixtures +from whatsapp_chatbot_python.calls.models import ( + CallEndReason, CallEvent, CallOptions, CallSession, CallState, RecordingOptions, +) +from whatsapp_chatbot_python.calls.service import WhatsAppCallService +from whatsapp_chatbot_python.calls.state_machine import CallStateMachine +from tests.calls.fakes import FakeApi, FakeRtc, FakeVoice, Scenario -class CallCleanupTest(unittest.IsolatedAsyncioTestCase): - make_session = fixtures.CallServiceTest.make_session - make_service = fixtures.CallServiceTest.make_service +class CleanupTest(unittest.IsolatedAsyncioTestCase): def setUp(self): - fixtures.CallServiceTest.setUp(self) - self.recorders = [] - recorders = self.recorders - - class ObservedRecorder(CallRecorder): - def __init__(self, logger): - super().__init__(logger) - self.finish_calls = 0 - self.finished_path = None - recorders.append(self) - - def finish(self): - self.finish_calls += 1 - self.finished_path = super().finish() - return self.finished_path - - patcher = patch("whatsapp_chatbot_python.calls.service.CallRecorder", ObservedRecorder) - patcher.start() - self.addCleanup(patcher.stop) - - def assert_recorder_closed(self, service): - recorder = self.recorders[-1] - self.assertEqual(recorder.finish_calls, 1) - self.assertTrue(recorder._caller.closed) - self.assertTrue(recorder._bot.closed) - if recorder._thread is not None: - self.assertFalse(recorder._thread.is_alive()) - self.assertIsNone(service._active_loop) - self.assertIsNone(service._active_runtime) - - async def test_execute_accepts_contract_keyword_arguments(self): - session, transition = self.make_session() - service = self.make_service() - await service.execute(session=session, transition=transition) - self.assertEqual(session.state, CallState.REMOTE_ENDED) - self.assert_recorder_closed(service) - - async def test_startup_errors_finish_recorder_and_still_propagate(self): - for stage in ("constructor", "start", "connect", "listeners"): - with self.subTest(stage=stage): - session, transition = self.make_session() - service = self.make_service() - if stage == "constructor": - target = "whatsapp_chatbot_python.calls.service.VoiceBotSession" - elif stage == "start": - target = "tests.calls.test_call_service.FakeVoice.start" - elif stage == "connect": - target = "tests.calls.test_call_service.FakeClient.connect" - else: - target = "tests.calls.test_call_service.FakeCalls.on" - - with patch(target, side_effect=RuntimeError("startup failed")): - with self.assertRaisesRegex(RuntimeError, "startup failed"): - await service.execute(session, transition) - self.assert_recorder_closed(service) - - async def cancel_at_stage(self, stage, *, repeat=False, hangup_fails=False): - fixtures.FakeClient.answer = stage != "ring" - fixtures.FakeClient.remote_hangup = stage == "cleanup" - session, apply = self.make_session() - service = self.make_service(ring=5, talk=5) - reached = asyncio.Event() - close_entered = asyncio.Event() - release_close = asyncio.Event() - cancel_seen = asyncio.Event() - if stage != "cleanup" and not repeat: - release_close.set() - - start = fixtures.FakeVoice.start - open_connection = fixtures.FakeCalls.openAsync - dial = fixtures.FakeClient.dialAsync - bridge = fixtures.FakeCalls.startAudioAsync - greet = fixtures.FakeVoice.greet_once - close = fixtures.FakeCalls.closeAsync - hangup = fixtures.FakeClient.hangUpAsync - - async def pause(name): - if stage == name: - reached.set() - await asyncio.Event().wait() - - async def voice_start(voice): - await start(voice) - await pause("start") - - async def calls_open(calls, **options): - await open_connection(calls, **options) - await pause("open") - - async def calls_dial(client, target): - await dial(client, target) - await pause("dial") - - async def calls_bridge(calls): - await pause("bridge") - await bridge(calls) - - async def voice_greet(voice): - await greet(voice) - # Exercise the real recording thread and MP3 encoding on cancellation. - recorder = voice.options["recorder"] - recorder.set_generation(1) - recorder.add_bot(b"\x01\x00" * 480, 1) - await pause("talk") - - async def calls_close(calls): - close_entered.set() - if stage == "cleanup": - reached.set() - await release_close.wait() - await close(calls) - - async def calls_hangup(client): - await hangup(client) - if hangup_fails: - raise RuntimeError("hangup failed") - - def transition(call_session, event, **options): - result = apply(call_session, event, **options) - if stage == "ring" and event == CallEvent.DIAL_ACCEPTED: - reached.set() - if event == CallEvent.SHUTDOWN_REQUESTED: - cancel_seen.set() - return result - - with ( - patch.object(fixtures.FakeVoice, "start", voice_start), - patch.object(fixtures.FakeCalls, "openAsync", calls_open), - patch.object(fixtures.FakeClient, "dialAsync", calls_dial), - patch.object(fixtures.FakeCalls, "startAudioAsync", calls_bridge), - patch.object(fixtures.FakeVoice, "greet_once", voice_greet), - patch.object(fixtures.FakeCalls, "closeAsync", calls_close), - patch.object(fixtures.FakeClient, "hangUpAsync", calls_hangup), + self.scenario = Scenario(remote_hangup=False) + self.apis = [] + self.voices = [] + self.fsm = CallStateMachine() + + def api_factory(*args, **kwargs): + api = FakeApi(self.scenario, *args, **kwargs) + self.apis.append(api) + return api + + def voice_factory(*args): + voice = FakeVoice(self.scenario, *args) + self.voices.append(voice) + return voice + + for path, replacement in ( + ("whatsapp_api_client_python.API.GreenApi", api_factory), + ("whatsapp_chatbot_python.calls.openai.RealtimeConversation", voice_factory), ): - task = asyncio.create_task(service.execute(session, transition)) - try: - await asyncio.wait_for(reached.wait(), timeout=1) - task.cancel("caller cancelled") - await asyncio.wait_for(cancel_seen.wait(), timeout=1) - if repeat: - await asyncio.wait_for(close_entered.wait(), timeout=1) - task.cancel("cancelled again") - await asyncio.sleep(0) - self.assertFalse(task.done()) - release_close.set() - with self.assertRaises(asyncio.CancelledError) as raised: - await asyncio.wait_for(task, timeout=1) - self.assertEqual(raised.exception.args, ("caller cancelled",)) - finally: - release_close.set() - if not task.done(): - task.cancel() - await asyncio.gather(task, return_exceptions=True) - - self.assert_recorder_closed(service) - recorder = self.recorders[-1] - if stage in {"talk", "cleanup"}: - self.assertIsNotNone(recorder.finished_path) - if recorder.finished_path is not None: - self.assertFalse(recorder.finished_path.exists()) - if stage == "cleanup": - self.assertEqual(session.state, CallState.REMOTE_ENDED) - else: - self.assertEqual(session.end_reason, CallEndReason.SHUTDOWN) - self.assertEqual(session.error_code, "cancelled") - self.assertTrue(fixtures.FakeVoice.instances[-1].closed) - if stage != "start": - client = fixtures.FakeClient.instances[-1] - self.assertTrue(client.calls.closed) - self.assertEqual(client.actions.count("hangup"), int(stage != "open")) - voice = fixtures.FakeVoice.instances[-1] - self.assertTrue(all(track.stopped for track in getattr(voice, "tracks", []))) - self.assertTrue(all(sink.closed for sink in getattr(voice, "sinks", []))) - - async def test_cancellation_finishes_recording_at_every_stage(self): - for stage in ("start", "open", "dial", "ring", "bridge", "talk", "cleanup"): + patcher = patch(path, replacement) + patcher.start() + self.addCleanup(patcher.stop) + + def service(self, *, setup=0.2): + return WhatsAppCallService( + api_url="https://example.invalid", id_instance="1", + api_token_instance="token", openai_api_key="key", + realtime_model="test", realtime_voice="marin", + options=CallOptions( + setup_seconds=setup, command_seconds=setup, + ring_seconds=0.2, talk_seconds=0.2, + shutdown_seconds=0.2, + recording=RecordingOptions(enabled=False), + ), + ) + + def session(self, incoming=False): + session = (CallSession.incoming( + sender_id="sender@c.us", call_id="call-1", language="ru", + incoming_deadline=monotonic() + 1, + ) if incoming else CallSession("sender@c.us", "sender@c.us", "ru")) + self.fsm.apply(session, CallEvent.ENQUEUED) + self.fsm.apply(session, CallEvent.DEQUEUED) + return session + + async def wait_action(self, action): + for _ in range(100): + if action in self.scenario.actions: + return + await asyncio.sleep(0.001) + self.fail(f"Missing action {action}: {self.scenario.actions}") + + async def test_cancellation_drains_cleanup_at_each_active_stage(self): + for stage, incoming in (("voice_start", False), ("open", False), + ("dial", False), ("accept", True), + ("bridge", False)): with self.subTest(stage=stage): - await self.cancel_at_stage(stage) - - async def test_repeated_cancel_waits_for_cleanup(self): - await self.cancel_at_stage("talk", repeat=True) - - async def test_hangup_error_does_not_replace_cancellation(self): - await self.cancel_at_stage("talk", hangup_fails=True) - - async def test_normal_success_still_returns_recording(self): - greet = fixtures.FakeVoice.greet_once - - async def record_audio(voice): - await greet(voice) - recorder = voice.options["recorder"] - recorder.set_generation(1) - recorder.add_bot(b"\x01\x00" * 480, 1) - - for remote_hangup in (True, False): - with self.subTest(remote_hangup=remote_hangup): - fixtures.FakeClient.remote_hangup = remote_hangup - session, transition = self.make_session() - service = self.make_service(talk=0.02) - with patch.object(fixtures.FakeVoice, "greet_once", record_audio): - result = await service.execute(session, transition) - self.assert_recorder_closed(service) - self.assertEqual(session.state, CallState.REMOTE_ENDED if remote_hangup else CallState.TALK_TIMEOUT) - self.assertIsNotNone(result.recording_path) + self.scenario = Scenario(state="inc-call" if incoming else "idle", + remote_hangup=False) + self.scenario.gates[stage] = asyncio.Event() + self.apis.clear() + self.voices.clear() + session = self.session(incoming) + task = asyncio.create_task(self.service().execute(session, self.fsm.apply)) try: - self.assertTrue(result.recording_path.exists()) + await self.wait_action(stage if stage not in ("dial",) else + ("dial", "sender@c.us")) + task.cancel("caller cancelled") + with self.assertRaises(asyncio.CancelledError): + await asyncio.wait_for(task, 0.5) + self.assertEqual((session.state, session.end_reason), + (CallState.CANCELLED, CallEndReason.SHUTDOWN)) + self.assertTrue(self.apis[0].session_closed) + self.assertTrue(self.voices[0].closed) + if self.apis[0].rtc is not None: + self.assertTrue(self.apis[0].rtc.closed) finally: - result.recording_path.unlink(missing_ok=True) - - async def test_close_failures_preserve_cleanup_order(self): - for failing_resource in ("calls", "voice"): - with self.subTest(failing_resource=failing_resource): - session, transition = self.make_session() - service = self.make_service() - actions = [] - calls_close = fixtures.FakeCalls.closeAsync - voice_close = fixtures.FakeVoice.close - - async def close_calls(calls): - actions.append("calls") - await calls_close(calls) - if failing_resource == "calls": - raise RuntimeError("RTC close failed") - - async def close_voice(voice): - actions.append("voice") - await voice_close(voice) - if failing_resource == "voice": - raise RuntimeError("voice close failed") - - finish = CallRecorder.finish - - def finish_recording(recorder): - actions.append("recording") - return finish(recorder) - - with ( - patch.object(fixtures.FakeCalls, "closeAsync", close_calls), - patch.object(fixtures.FakeVoice, "close", close_voice), - patch.object(CallRecorder, "finish", finish_recording), - ): - await service.execute(session, transition) - self.assertEqual(actions, ["calls", "voice", "recording"]) - self.assertEqual(session.state, CallState.REMOTE_ENDED) - self.assert_recorder_closed(service) - - async def test_failed_error_transition_still_finishes_recorder(self): - fixtures.FakeClient.open_error = True - session, apply = self.make_session() - service = self.make_service() - - def transition(call_session, event, **options): - if event == CallEvent.INTERNAL_ERROR: - raise ValueError("error callback failed") - return apply(call_session, event, **options) - - with self.assertRaisesRegex(ValueError, "error callback failed"): - await service.execute(session, transition) - self.assert_recorder_closed(service) - self.assertTrue(fixtures.FakeClient.instances[-1].calls.closed) - self.assertTrue(fixtures.FakeVoice.instances[-1].closed) - - async def test_failed_cancellation_transition_preserves_original_cancellation(self): - session, apply = self.make_session() - service = self.make_service() - cancellation = asyncio.CancelledError("original cancellation") - - def transition(call_session, event, **options): - if event == CallEvent.SHUTDOWN_REQUESTED: - raise ValueError("shutdown callback failed") - return apply(call_session, event, **options) - - with patch.object(fixtures.FakeVoice, "start", side_effect=cancellation): - with self.assertRaises(asyncio.CancelledError) as raised: - await service.execute(session, transition) - self.assertIs(raised.exception, cancellation) - self.assert_recorder_closed(service) - self.assertTrue(fixtures.FakeVoice.instances[-1].closed) - - async def test_sink_creation_failure_stops_output_track(self): - session, transition = self.make_session() - service = self.make_service() - with patch.object(fixtures.FakeVoice, "new_input_sink", side_effect=RuntimeError("sink failed")): - await service.execute(session, transition) - voice = fixtures.FakeVoice.instances[-1] - self.assertTrue(voice.tracks[0].stopped) - self.assertEqual(session.state, CallState.FAILED) - self.assertEqual(session.error_code, "RuntimeError") - self.assert_recorder_closed(service) - - async def test_sink_close_failure_stops_track_and_closes_voice(self): - session, transition = self.make_session() - service = self.make_service() - with patch.object(fixtures.FakeSink, "close", side_effect=RuntimeError("sink close failed")): - await service.execute(session, transition) - voice = fixtures.FakeVoice.instances[-1] - self.assertTrue(voice.tracks[0].stopped) - self.assertTrue(voice.closed) + self.scenario.gates[stage].set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + async def test_repeated_cancellation_waits_for_blocked_close(self): + self.scenario.gates["rtc_close"] = asyncio.Event() + session = self.session() + task = asyncio.create_task(self.service().execute(session, self.fsm.apply)) + try: + await self.wait_action("bridge") + task.cancel() + await self.wait_action("rtc_close") + task.cancel() + self.assertFalse(task.done()) + self.scenario.gates["rtc_close"].set() + with self.assertRaises(asyncio.CancelledError): + await asyncio.wait_for(task, 0.5) + self.assertTrue(self.apis[0].session_closed) + self.assertTrue(self.voices[0].closed) + finally: + self.scenario.gates["rtc_close"].set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + async def test_rtc_close_failure_still_closes_http_session_and_voice(self): + self.scenario.remote_hangup = True + session = self.session() + + async def broken_close(rtc): + rtc.api.scenario.actions.append("rtc_close") + raise RuntimeError("RTC close failed") + + with patch.object(FakeRtc, "closeAsync", broken_close): + with self.assertRaisesRegex(RuntimeError, "RTC close failed"): + await asyncio.wait_for(self.service().execute(session, self.fsm.apply), 0.5) + + self.assertTrue(self.apis[0].session_closed) + self.assertTrue(self.voices[0].closed) + # The remote outcome is already terminal; cleanup errors surface separately. self.assertEqual(session.state, CallState.REMOTE_ENDED) - self.assert_recorder_closed(service) + + async def test_rtc_close_timeout_disables_service_and_continues_later(self): + self.scenario.gates["rtc_close"] = asyncio.Event() + self.scenario.remote_hangup = True + session = self.session() + service = self.service() + + try: + with self.assertRaises(TimeoutError): + await asyncio.wait_for(service.execute(session, self.fsm.apply), 1) + + self.assertFalse(service.healthy) + self.assertTrue(self.apis[0].session_closed) + self.assertTrue(self.voices[0].closed) + self.assertFalse(self.apis[0].rtc.closed) + finally: + self.scenario.gates["rtc_close"].set() + + await asyncio.wait_for(service._runner_with_incomplete_cleanup._resources.api._rtc_close_task, 0.5) + self.assertTrue(self.apis[0].rtc.closed) + + async def test_slow_operations_cleanup_does_not_skip_other_owners(self): + from whatsapp_chatbot_python.calls.call_runner import Operations + + release = asyncio.Event() + self.scenario.remote_hangup = True + service = self.service() + + async def delayed_close(operations): + await release.wait() + + try: + with patch.object(Operations, "close", delayed_close): + with self.assertRaises(TimeoutError): + await asyncio.wait_for( + service.execute(self.session(), self.fsm.apply), 1, + ) + + self.assertFalse(service.healthy) + self.assertFalse(self.apis[0].session_closed) + self.assertFalse(self.voices[0].closed) + finally: + release.set() + + cleanup = service._runner_with_incomplete_cleanup._resources._cleanup_still_running + await asyncio.wait_for(cleanup, 0.5) + self.assertTrue(self.apis[0].session_closed) + self.assertTrue(self.apis[0].rtc.closed) + self.assertTrue(self.voices[0].closed) + + async def test_late_prepare_cannot_open_rtc_after_cleanup_started(self): + release = asyncio.Event() + service = self.service(setup=2) + + async def late_voice_start(voice): + voice.scenario.actions.append("voice_start") + try: + await release.wait() + except asyncio.CancelledError: + await release.wait() + voice.started = True + + with patch.object(FakeVoice, "start", late_voice_start): + execution = asyncio.create_task(service.execute(self.session(), self.fsm.apply)) + try: + await self.wait_action("voice_start") + service.request_stop() + with self.assertRaises(TimeoutError): + await asyncio.wait_for(execution, 1) + + self.assertFalse(service.healthy) + self.assertFalse(self.apis[0].session_closed) + self.assertIsNone(self.apis[0].rtc) + finally: + release.set() + + cleanup = service._runner_with_incomplete_cleanup._resources._cleanup_still_running + await asyncio.wait_for(cleanup, 0.5) + + self.assertTrue(self.apis[0].session_closed) + self.assertTrue(self.voices[0].closed) + self.assertIsNone(self.apis[0].rtc) diff --git a/tests/calls/test_call_recording.py b/tests/calls/test_call_recording.py index 2a589c8..75cbb1f 100644 --- a/tests/calls/test_call_recording.py +++ b/tests/calls/test_call_recording.py @@ -1,77 +1,160 @@ +"""MP3, PCM timing, and recording ownership guarantees.""" + +import asyncio +import tempfile +import unittest from array import array +from fractions import Fraction +from io import BytesIO +from pathlib import Path +from threading import Event from unittest.mock import patch -from queue import Full -from whatsapp_chatbot_python.calls.recording import CallRecorder, RATE -import logging -import unittest -import av - - -def pcm(value: int, samples: int) -> bytes: - return array("h", [value] * samples).tobytes() - - -class CallRecordingTest(unittest.TestCase): - def test_mixes_both_sides_and_keeps_pause_and_reconnect(self) -> None: - clock = [0.0] - recorder = CallRecorder(logging.getLogger("recording-test")) - - with patch("whatsapp_chatbot_python.calls.recording.monotonic", side_effect=lambda: clock[0]): - recorder.set_generation(1) - recorder.start() - - clock[0] = 0.1 - - recorder.add_caller(pcm(4000, RATE // 10), 1) # capture starts at zero - clock[0] = 0.0 - - recorder.add_bot(pcm(3000, RATE // 10), 1) - recorder.set_generation(2) - - clock[0] = 0.2 - - recorder.add_bot(pcm(10000, RATE // 10), 1) # old bridge ignored - recorder.add_bot(pcm(5000, RATE // 10), 2) - - path = recorder.finish() - - self.assertIsNotNone(path) - - try: - with av.open(str(path)) as container: - frames = [frame for packet in container.demux(audio=0) for frame in packet.decode()] - - converted = av.AudioResampler(format="s16", layout="mono", rate=RATE) - samples = array("h") - - for frame in frames: - for output in converted.resample(frame): - samples.frombytes(bytes(output.planes[0])[:output.samples * 2]) - - # MP3 is lossy; check broad levels in each time segment. - def level(start, end): - section = samples[int(start * RATE):int(end * RATE)] - return sum(section) / len(section) - - self.assertGreater(level(0.03, 0.07), 5500) - self.assertLess(abs(level(0.13, 0.17)), 500) - self.assertGreater(level(0.23, 0.27), 3500) - self.assertLess(level(0.23, 0.27), 8000) - finally: - path.unlink(missing_ok=True) +import av +from aiortc.mediastreams import MediaStreamError - def test_no_audio_or_queue_overflow_disables_recording(self) -> None: - recorder = CallRecorder(logging.getLogger("recording-test")) +from whatsapp_chatbot_python.calls.audio import CallerSink, RecordingTap +from whatsapp_chatbot_python.calls.models import CallExecutionResult, PCM, PcmChunk, RecordingOptions +from whatsapp_chatbot_python.calls.recorder import CallRecorder, Mp3Encoder, Track - self.assertIsNone(recorder.finish()) - recorder = CallRecorder(logging.getLogger("recording-test")) +def pcm(value, samples=PCM.samples_per_frame): + return array("h", [value] * samples).tobytes() - recorder.set_generation(1) - recorder.start() - with patch.object(recorder._queue, "put_nowait", side_effect=Full): - recorder.add_bot(pcm(1000, 480), 1) +class RecordingTest(unittest.IsolatedAsyncioTestCase): + async def test_result_context_releases_or_transfers_recording(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "call.mp3" + path.write_bytes(b"recording") + + with CallExecutionResult(path) as result: + self.assertEqual(result.recording_path, path) + + self.assertFalse(path.exists()) + self.assertIsNone(result.recording_path) + + path.write_bytes(b"recording") + with CallExecutionResult(path) as result: + self.assertEqual(result.take_recording(), path) + + self.assertTrue(path.exists()) + path.unlink() + + async def test_real_mp3_contains_both_sides_and_caller_owns_output(self): + with tempfile.TemporaryDirectory() as directory: + recorder = CallRecorder(RecordingOptions(directory=Path(directory))) + recorder.write(Track.CALLER, PcmChunk(0, pcm(2000, PCM.rate // 4))) + recorder.write(Track.BOT, PcmChunk(0, pcm(3000, PCM.rate // 4))) + exported = await recorder.finish() + self.assertIsNotNone(exported) + self.assertTrue(exported.exists()) + self.assertEqual(exported, recorder.take()) + await recorder.close() + self.assertTrue(exported.exists(), "ownership transferred to caller") + with av.open(str(exported)) as container: + frames = list(container.decode(audio=0)) + self.assertTrue(frames) + self.assertGreater(sum(frame.samples for frame in frames), PCM.rate // 5) + exported.unlink() + + async def test_mixer_adds_tracks_and_clips_without_wraparound(self): + encoder = Mp3Encoder(RecordingOptions(export_block_samples=4)) + left = BytesIO(pcm(20000, 4)) + right = BytesIO(pcm(20000, 4)) + mixed = array("h") + mixed.frombytes(encoder._mix_block({Track.CALLER: left, Track.BOT: right})) + self.assertEqual(list(mixed), [32767] * 4) + + async def test_no_audio_or_capture_failure_returns_no_recording(self): + recorder = CallRecorder(RecordingOptions()) + self.assertIsNone(await recorder.finish()) + await recorder.close() + + recorder = CallRecorder(RecordingOptions()) + with patch.object(recorder._capture, "write", side_effect=BufferError("full")): + recorder.write(Track.CALLER, PcmChunk(0, pcm(1000))) + self.assertIsNone(await recorder.finish()) + await recorder.close() + + async def test_mp3_export_does_not_block_event_loop(self): + started, release = Event(), Event() + with tempfile.TemporaryDirectory() as directory: + recorder = CallRecorder(RecordingOptions(directory=Path(directory))) + recorder.write(Track.CALLER, PcmChunk(0, pcm(1000))) + + def slow_encode(tracks, path): + started.set() + release.wait(1) + path.write_bytes(b"mp3 placeholder") + + with patch.object(recorder._encoder, "encode", side_effect=slow_encode): + task = asyncio.create_task(recorder.finish()) + try: + self.assertTrue(await asyncio.to_thread(started.wait, 0.5)) + await asyncio.sleep(0.005) + self.assertFalse(task.done(), "export should still run on a worker thread") + finally: + release.set() + exported = await asyncio.wait_for(task, 0.5) + self.assertEqual(exported, recorder.take()) + exported.unlink() + await recorder.close() + + async def test_callback_delay_does_not_insert_false_silence(self): + class Capture: + def __init__(self): + self.chunks = [] + + def write(self, track, chunk): + self.chunks.append(chunk) - self.assertIsNone(recorder.finish()) + clock = [0.0] + capture = Capture() + with patch("whatsapp_chatbot_python.calls.audio.monotonic", side_effect=lambda: clock[0]): + tap = RecordingTap(capture) + tap.activate() + tap.write(Track.BOT, pcm(1000)) + clock[0] = 0.1 # Callback jitter is not a media gap. + tap.write(Track.BOT, pcm(2000)) + self.assertEqual(capture.chunks[1].position - capture.chunks[0].position, + PCM.samples_per_frame) + + async def test_caller_media_timestamp_gap_is_kept_without_callback_jitter(self): + """RED: a real media gap must be represented in the captured timeline.""" + class Capture: + def __init__(self): + self.chunks = [] + + def write(self, track, chunk): + self.chunks.append((track, chunk)) + + def disable(self, error): + raise AssertionError(error) + + class InputTrack: + def __init__(self): + self.index = 0 + + async def recv(self): + if self.index == 2: + raise MediaStreamError + frame = av.AudioFrame(format="s16", layout="mono", samples=PCM.samples_per_frame) + frame.planes[0].update(pcm(1000)) + frame.sample_rate = PCM.rate + frame.pts = (0, PCM.rate // 10)[self.index] + frame.time_base = Fraction(1, PCM.rate) + self.index += 1 + return frame + + class Sink: + async def write_audio(self, data): + pass + + capture = Capture() + tap = RecordingTap(capture) + tap.activate() + await CallerSink(Sink(), tap, lambda event: None)._consume(InputTrack()) + positions = [chunk.position for track, chunk in capture.chunks if track == Track.CALLER] + self.assertEqual(len(positions), 2) + self.assertEqual(positions[1] - positions[0], PCM.rate // 10) diff --git a/tests/calls/test_call_runtime.py b/tests/calls/test_call_runtime.py index d95baec..c7da2e9 100644 --- a/tests/calls/test_call_runtime.py +++ b/tests/calls/test_call_runtime.py @@ -1,61 +1,97 @@ -from whatsapp_chatbot_python.calls.models import CallState -from whatsapp_chatbot_python.calls.runtime import CallRuntime, RuntimeEvent, RuntimeEventType +"""Mailbox and deadline semantics used by every call direction.""" + import asyncio import unittest +from time import monotonic + +from whatsapp_chatbot_python.calls.call_runner import Events, Operations +from whatsapp_chatbot_python.calls.models import ( + Command, CommandCompleted, CommandFailed, DeadlineExpired, DeadlineKind, + Deadlines, StopRequested, +) + + +class RuntimeTest(unittest.IsolatedAsyncioTestCase): + async def test_observation_before_deadline_wins(self): + events = Events() + deadlines = Deadlines() + event = StopRequested(observed_at=monotonic() - 1) + deadlines.arm(DeadlineKind.OFFER, monotonic() - 0.5) + events.emit(event) + self.assertIs(await events.receive(deadlines), event) + + async def test_observation_on_or_after_deadline_is_late(self): + for offset in (0, 0.01): + with self.subTest(offset=offset): + events = Events() + deadlines = Deadlines() + due = monotonic() - 1 + deadlines.arm(DeadlineKind.OFFER, due) + event = StopRequested(observed_at=due + offset) + events.emit(event) + expired = await events.receive(deadlines) + self.assertIsInstance(expired, DeadlineExpired) + self.assertEqual(expired.kind, DeadlineKind.OFFER) + self.assertIs(await events.receive(deadlines), event) + + async def test_empty_mailbox_returns_deadline(self): + events = Events() + deadlines = Deadlines() + deadlines.arm(DeadlineKind.RING, monotonic() + 0.001) + result = await asyncio.wait_for(events.receive(deadlines), 0.2) + self.assertEqual(result.kind, DeadlineKind.RING) + + async def test_waiter_cancellation_is_not_swallowed(self): + events = Events() + deadlines = Deadlines() + task = asyncio.create_task(events.receive(deadlines)) + await asyncio.sleep(0) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + events.close() + + async def test_event_from_another_thread_wakes_the_consumer(self): + events = Events() + event = StopRequested() + waiter = asyncio.create_task(events.receive(Deadlines())) + self.assertTrue(await asyncio.to_thread(events.emit, event)) + self.assertIs(await asyncio.wait_for(waiter, 0.2), event) + events.close() + + async def test_named_operation_runs_once_and_reports_completion(self): + reported = [] + operations = Operations(reported.append) + calls = 0 + + async def work(): + nonlocal calls + calls += 1 + + operations.start(Command.GREET, work, 1) + operations.start(Command.GREET, work, 1) + await asyncio.gather(*operations._tasks.values()) + await operations.close() + self.assertEqual(calls, 1) + self.assertEqual([type(event) for event in reported], [CommandCompleted]) + + async def test_hung_operation_reports_timeout_and_is_cancelled(self): + reported = [] + operations = Operations(reported.append) + entered = asyncio.Event() + cancelled = asyncio.Event() + async def hung(): + entered.set() + try: + await asyncio.Event().wait() + finally: + cancelled.set() -class CallRuntimeTest(unittest.IsolatedAsyncioTestCase): - async def test_queued_event_wins_over_expired_timer(self) -> None: - runtime = CallRuntime( - ring_timeout_seconds=0, - talk_timeout_seconds=1, - bridge_timeout_seconds=1, - ) - runtime.start_ringing() - runtime.events.put_nowait(RuntimeEvent.bridge_ready()) - - event = await runtime.next_event(CallState.RINGING) - - self.assertIsNotNone(event) - self.assertEqual(event.type, RuntimeEventType.BRIDGE_READY) - - - async def test_empty_queue_returns_none_when_timer_expires(self) -> None: - runtime = CallRuntime(0, 1, 1) - runtime.start_ringing() - self.assertIsNone(await runtime.next_event(CallState.RINGING)) - - async def test_cancellation_is_not_swallowed_when_event_becomes_ready(self) -> None: - waiting = asyncio.Event() - - class ObservedQueue(asyncio.Queue): - async def get(self): - waiting.set() - return await super().get() - - runtime = CallRuntime(5, 1, 1, events=ObservedQueue()) - runtime.start_ringing() - - async def consume(): - event = await runtime.next_event(CallState.RINGING) - # Allow cancellation to arrive even if the event was delivered first. - await asyncio.sleep(0) - return event - - task = asyncio.create_task(consume()) - try: - await asyncio.wait_for(waiting.wait(), timeout=1) - runtime.events.put_nowait(RuntimeEvent.bridge_ready()) - # Resume the queue reader, then cancel before wait_for's parent resumes. - asyncio.get_running_loop().call_soon(task.cancel, "caller cancelled") - with self.assertRaises(asyncio.CancelledError) as raised: - await asyncio.wait_for(task, timeout=1) - self.assertEqual(raised.exception.args, ("caller cancelled",)) - finally: - if not task.done(): - task.cancel() - await asyncio.gather(task, return_exceptions=True) - - -if __name__ == "__main__": - unittest.main() + operations.start(Command.DIAL, hung, 0.005) + await asyncio.wait_for(entered.wait(), 0.2) + await asyncio.wait_for(cancelled.wait(), 0.2) + await operations.close() + self.assertEqual(len(reported), 1) + self.assertIsInstance(reported[0], CommandFailed) + self.assertIsInstance(reported[0].error, TimeoutError) diff --git a/tests/calls/test_call_service.py b/tests/calls/test_call_service.py index 79a16f0..9d7588c 100644 --- a/tests/calls/test_call_service.py +++ b/tests/calls/test_call_service.py @@ -1,427 +1,369 @@ -from pathlib import Path -from tempfile import NamedTemporaryFile -from threading import Thread -from unittest.mock import patch +"""Call-service scenarios exercise the real runner, FSM, RTC adapter and cleanup.""" + import asyncio -import logging import unittest +from contextlib import suppress +from time import monotonic +from unittest.mock import patch -from whatsapp_chatbot_python.calls.models import CallEndReason, CallEvent, CallSession, CallState +from whatsapp_chatbot_python.calls.models import ( + CallEndReason, CallEvent, CallOptions, CallSession, CallState, ProviderError, + RecordingOptions, +) from whatsapp_chatbot_python.calls.service import WhatsAppCallService from whatsapp_chatbot_python.calls.state_machine import CallStateMachine - -class FakeVoice: - instances = [] - fail_start = False - fail_greeting = False - - def __init__(self, **options): - self.options = options - self.greetings = 0 - self.closed = False - self.instances.append(self) - - async def start(self): - if self.fail_start: - raise RuntimeError("OpenAI unavailable") - - async def close(self): - self.closed = True - - async def greet_once(self): - if self.greetings: - return - - self.greetings += 1 - - if self.fail_greeting: - self.options["on_error"](RuntimeError("OpenAI failed")) - - async def new_output_track(self): - self.tracks = getattr(self, "tracks", []) - track = FakeTrack() - - self.tracks.append(track) - - return track - - def new_input_sink(self): - self.sinks = getattr(self, "sinks", []) - sink = FakeSink() - - self.sinks.append(sink) - - return sink - - -class FakeTrack: - def __init__(self): - self.stopped = False - - def stop(self): - self.stopped = True - - -class FakeSink: - def __init__(self): - self.closed = False - - async def attach(self, track): - pass - - async def close(self): - self.closed = True - - -class FakeCalls: - def __init__(self, client): - self.client = client - self.listeners = {} - self.closed = False - self.audio_factory = None - self.devices = [] - - def on(self, name, callback): - self.listeners.setdefault(name, []).append(callback) - - def emit(self, name, detail=None): - for callback in self.listeners.get(name, []): - callback(detail) - - async def openAsync(self, *, timeout): - self.client.actions.append("open") - - if self.client.open_error: - raise RuntimeError("callsRtc connection refused") - - asyncio.get_running_loop().call_soon( - self.emit, "state", CallStateDetail(self.client.initial_state) - ) - - async def startAudioAsync(self): - self.client.actions.append("bridge") - self.devices.append(await self.audio_factory()) - - if self.client.bridge_error: - raise RuntimeError("audio bridge failed") - - if self.client.answer and not self.client.answer_after_bridge: - self.emit("state", CallStateDetail("on-call")) - - if self.client.answer and self.client.answer_after_bridge: - asyncio.get_running_loop().call_later( - 0.001, self.emit, "state", - CallStateDetail("on-call"), - ) - - if self.client.reconnect: - self.emit("disconnect", {"permanent": False}) - await self.devices[-1].close() - self.devices.append(await self.audio_factory()) - - if self.client.remote_hangup: - asyncio.get_running_loop().call_later( - 0.01, self.emit, "state", - CallStateDetail("idle", "hangup"), - ) - - async def closeAsync(self): - if self.client.close_hangs: - await asyncio.Event().wait() - - for audio in self.devices: - await audio.close() - - self.closed = True - - -class CallStateDetail: - def __init__(self, state, reason=None): - self.state = state - self.reason = reason - - -class FakeClient: - initial_state = "idle" - answer = True - remote_hangup = True - bridge_error = False - open_error = False - reconnect = False - answer_after_bridge = False - close_hangs = False - instances = [] - - def __init__(self, id_instance, api_token_instance, *, host): - self.voip = self - self.calls = FakeCalls(self) - self.actions = [] - self.instances.append(self) - - def connect(self, *, audio_factory): - self.calls.audio_factory = audio_factory - return self.calls - - async def dialAsync(self, target): - self.actions.append(("dial", target)) - - async def hangUpAsync(self): - self.actions.append("hangup") +from tests.calls.fakes import FakeApi, FakeVoice, Scenario class CallServiceTest(unittest.IsolatedAsyncioTestCase): def setUp(self): - for name, value in { - "initial_state": "idle", - "answer": True, - "remote_hangup": True, - "bridge_error": False, - "open_error": False, - "reconnect": False, - "answer_after_bridge": False, - "close_hangs": False, - }.items(): - setattr(FakeClient, name, value) - - FakeClient.instances = [] - FakeVoice.instances = [] - FakeVoice.fail_start = False - FakeVoice.fail_greeting = False - - for target, fake in ( - ("whatsapp_chatbot_python.calls.service.GreenAPI", FakeClient), - ("whatsapp_chatbot_python.calls.service.VoiceBotSession", FakeVoice), + self.scenario = Scenario() + self.apis = [] + self.voices = [] + self.fsm = CallStateMachine() + + def create_api(*args, **kwargs): + api = FakeApi(self.scenario, *args, **kwargs) + self.apis.append(api) + return api + + def create_voice(*args): + voice = FakeVoice(self.scenario, *args) + self.voices.append(voice) + return voice + + for target, replacement in ( + ("whatsapp_api_client_python.API.GreenApi", create_api), + ("whatsapp_chatbot_python.calls.openai.RealtimeConversation", create_voice), ): - patcher = patch(target, fake) + patcher = patch(target, replacement) patcher.start() self.addCleanup(patcher.stop) - def make_session(self): - fsm = CallStateMachine() - session = CallSession("sender@c.us", "sender@c.us", "ru") - - fsm.apply(session, CallEvent.ENQUEUED) - fsm.apply(session, CallEvent.DEQUEUED) - - return session, fsm.apply - - def make_service(self, ring=1, talk=1, shutdown=10): - logger = logging.getLogger("call-service-test") - logger.disabled = True + def service(self, *, ring=0.2, talk=0.2): return WhatsAppCallService( api_url="https://example.invalid", id_instance="1", - api_token_instance="token", openai_api_key="test-key", - realtime_model="gpt-realtime-2.1", realtime_voice="marin", - ring_timeout_seconds=ring, talk_timeout_seconds=talk, - shutdown_timeout_seconds=shutdown, logger=logger, + api_token_instance="token", openai_api_key="explicit-key", + realtime_model="realtime-test", realtime_voice="marin", + options=CallOptions( + ring_seconds=ring, talk_seconds=talk, setup_seconds=0.1, + command_seconds=0.1, shutdown_seconds=0.1, + recording=RecordingOptions(enabled=False), + ), ) - async def test_dial_voice_bridge_and_remote_hangup(self): - session, transition = self.make_session() - - await self.make_service().execute(session, transition) - - client = FakeClient.instances[0] - voice = FakeVoice.instances[0] - - self.assertEqual(client.actions, ["open", ("dial", "sender@c.us"), "bridge"]) + def outgoing(self): + session = CallSession("sender@c.us", "sender@c.us", "ru") + self.fsm.apply(session, CallEvent.ENQUEUED) + self.fsm.apply(session, CallEvent.DEQUEUED) + return session + + def incoming(self, *, call_id="call-1", deadline=None): + session = CallSession.incoming( + sender_id="sender@c.us", call_id=call_id, language="ru", + incoming_deadline=deadline if deadline is not None else monotonic() + 1, + ) + self.fsm.apply(session, CallEvent.ENQUEUED) + self.fsm.apply(session, CallEvent.DEQUEUED) + return session + + async def execute(self, service, session): + return await asyncio.wait_for(service.execute(session, self.fsm.apply), 1) + + async def wait_action(self, action): + for _ in range(100): + if action in self.scenario.actions: + return + await asyncio.sleep(0.001) + self.fail(f"Missing action {action}: {self.scenario.actions}") + + async def test_outgoing_answer_greets_once_and_preserves_remote_reason(self): + session = self.outgoing() + result = await self.execute(self.service(), session) self.assertEqual(session.state, CallState.REMOTE_ENDED) self.assertEqual(session.end_reason, CallEndReason.REMOTE_HANGUP) - self.assertEqual(voice.greetings, 1) - self.assertTrue(voice.closed) - self.assertTrue(client.calls.closed) - self.assertTrue(voice.tracks[0].stopped) - self.assertTrue(voice.sinks[0].closed) - - async def test_reconnect_keeps_one_conversation_and_greeting(self): - FakeClient.reconnect = True - session, transition = self.make_session() - - await self.make_service().execute(session, transition) - self.assertEqual(len(FakeVoice.instances), 1) - self.assertEqual(FakeVoice.instances[0].greetings, 1) - self.assertEqual(len(FakeClient.instances[0].calls.devices), 2) - self.assertEqual(len(FakeVoice.instances[0].tracks), 2) - self.assertTrue(all(track.stopped for track in FakeVoice.instances[0].tracks)) - self.assertTrue(all(sink.closed for sink in FakeVoice.instances[0].sinks)) - - async def test_bridge_before_answer_greets_only_after_answer(self): - FakeClient.answer_after_bridge = True - session, transition = self.make_session() - - await self.make_service().execute(session, transition) - self.assertEqual(session.state, CallState.REMOTE_ENDED) - self.assertEqual(FakeVoice.instances[0].greetings, 1) - - async def test_voice_failure_hangs_up_once(self): - FakeClient.remote_hangup = False - FakeVoice.fail_greeting = True - session, transition = self.make_session() - - await self.make_service().execute(session, transition) - self.assertEqual(session.state, CallState.FAILED) - self.assertEqual(session.end_reason, CallEndReason.ERROR) - self.assertEqual(FakeClient.instances[0].actions.count("hangup"), 1) - - async def test_openai_start_failure_does_not_dial(self): - FakeVoice.fail_start = True - session, transition = self.make_session() - - with self.assertRaises(RuntimeError): - await self.make_service().execute(session, transition) - - self.assertEqual(FakeClient.instances, []) - self.assertTrue(FakeVoice.instances[0].closed) - - async def test_ring_and_talk_timeout_hang_up(self): - for answer, expected in ((False, CallState.RING_TIMEOUT), (True, CallState.TALK_TIMEOUT)): - with self.subTest(expected=expected): - FakeClient.answer = answer - FakeClient.remote_hangup = False - session, transition = self.make_session() + self.assertEqual(session.remote_reason, "hangup") + self.assertTrue(session.remote_accepted and session.bridge_ready) + self.assertEqual(self.voices[0].greetings, [None]) + self.assertIn(("dial", "sender@c.us"), self.scenario.actions) + self.assertTrue(self.apis[0].session_closed) + self.assertTrue(self.apis[0].rtc.closed) + self.assertTrue(self.voices[0].closed) + self.assertIsNone(result.recording_path) - await self.make_service(ring=0.01, talk=0.01).execute(session, transition) - self.assertEqual(session.state, expected) - self.assertEqual(FakeClient.instances[-1].actions.count("hangup"), 1) + async def test_incoming_accepts_matching_offer_and_greets_after_answer(self): + self.scenario.state = "inc-call" + session = self.incoming() + await self.execute(self.service(), session) + self.assertEqual(session.state, CallState.REMOTE_ENDED) + self.assertTrue(session.incoming_accept_completed) + self.assertLess(self.scenario.actions.index("accept"), self.scenario.actions.index("greet")) + self.assertTrue(self.apis[0].session_closed) + + async def test_incoming_call_id_takes_priority_over_pn_or_lid_address(self): + self.scenario.state = "inc-call" + self.scenario.wid = "sender@lid" + session = self.incoming() + await self.execute(self.service(), session) + self.assertEqual(session.state, CallState.REMOTE_ENDED) + self.assertIn("accept", self.scenario.actions) async def test_busy_instance_is_not_dialed(self): - FakeClient.initial_state = "on-call" - session, transition = self.make_session() - - await self.make_service().execute(session, transition) + self.scenario.state = "on-call" + session = self.outgoing() + with self.assertRaisesRegex(RuntimeError, "already has a call"): + await self.execute(self.service(), session) self.assertEqual(session.state, CallState.FAILED) - self.assertEqual(FakeClient.instances[0].actions, ["open"]) - - async def test_socket_open_failure_does_not_dial(self): - FakeClient.open_error = True - session, transition = self.make_session() - - await self.make_service().execute(session, transition) - + self.assertFalse(any(isinstance(a, tuple) and a[0] == "dial" for a in self.scenario.actions)) + self.assertNotIn("hangup", self.scenario.actions) + self.assertTrue(self.apis[0].session_closed) + + async def test_voice_setup_failure_never_dials(self): + self.scenario.voice_error = True + session = self.outgoing() + with self.assertRaisesRegex(RuntimeError, "voice unavailable"): + await self.execute(self.service(), session) self.assertEqual(session.state, CallState.FAILED) - self.assertEqual(FakeClient.instances[0].actions, ["open"]) - self.assertTrue(FakeClient.instances[0].calls.closed) - - async def test_hung_connection_close_has_deadline(self): - FakeClient.close_hangs = True - session, transition = self.make_session() - - await asyncio.wait_for(self.make_service(shutdown=0.01).execute(session, transition), timeout=1) - self.assertEqual(session.state, CallState.REMOTE_ENDED) - - async def test_bridge_failure_hangs_up(self): - FakeClient.bridge_error = True - session, transition = self.make_session() - - await self.make_service().execute(session, transition) + self.assertNotIn("open", self.scenario.actions) + self.assertFalse(any(isinstance(a, tuple) and a[0] == "dial" for a in self.scenario.actions)) + self.assertTrue(self.apis[0].session_closed) + + async def test_rtc_open_failure_never_dials(self): + self.scenario.open_error = True + session = self.outgoing() + with self.assertRaisesRegex(RuntimeError, "RTC unavailable"): + await self.execute(self.service(), session) self.assertEqual(session.state, CallState.FAILED) - self.assertEqual(FakeClient.instances[0].actions.count("hangup"), 1) - - async def test_shutdown_hangs_up(self): - FakeClient.answer = False - FakeClient.remote_hangup = False - session, transition = self.make_session() - - service = self.make_service() - task = asyncio.create_task(service.execute(session, transition)) - - while session.state != CallState.RINGING: - await asyncio.sleep(0) - - service.request_stop() - - await task - - self.assertEqual(session.end_reason, CallEndReason.SHUTDOWN) - self.assertEqual(FakeClient.instances[0].actions.count("hangup"), 1) - - async def test_shutdown_from_another_thread_hangs_up(self): - FakeClient.answer = False - FakeClient.remote_hangup = False - session, transition = self.make_session() - service = self.make_service() - task = asyncio.create_task(service.execute(session, transition)) - - async def wait_for_ringing(): - while session.state != CallState.RINGING: - if task.done(): - await task - self.fail(f"Call finished before ringing: {session.state}") - await asyncio.sleep(0) - - await asyncio.wait_for(wait_for_ringing(), timeout=1) - - thread = Thread(target=service.request_stop) - thread.start() - thread.join(timeout=1) - self.assertFalse(thread.is_alive()) - await asyncio.wait_for(task, timeout=1) - - self.assertEqual(session.end_reason, CallEndReason.SHUTDOWN) - self.assertEqual(FakeClient.instances[0].actions.count("hangup"), 1) - - async def test_stopped_executor_does_not_start_resources(self): - service = self.make_service() - service.request_stop() - for _ in range(2): - session, transition = self.make_session() - result = await service.execute(session, transition) - self.assertEqual(session.end_reason, CallEndReason.SHUTDOWN) - self.assertIsNone(result.recording_path) - self.assertEqual(FakeClient.instances, []) - self.assertEqual(FakeVoice.instances, []) - - async def test_executor_can_run_successive_calls(self): - service = self.make_service() - for _ in range(2): - session, transition = self.make_session() - await service.execute(session, transition) - self.assertEqual(session.state, CallState.REMOTE_ENDED) - self.assertEqual(len(FakeClient.instances), 2) - self.assertEqual(len(FakeVoice.instances), 2) - self.assertTrue(all(voice.closed for voice in FakeVoice.instances)) - self.assertIsNone(service._active_loop) - self.assertIsNone(service._active_runtime) - - async def test_failed_conversation_discards_partial_recording(self): - FakeClient.remote_hangup = False - FakeVoice.fail_greeting = True - session, transition = self.make_session() - - with NamedTemporaryFile(suffix=".mp3", delete=False) as file: - path = Path(file.name) - - self.addCleanup(path.unlink, missing_ok=True) - - class FakeRecorder: - def __init__(self, logger): - pass - - def start(self): - pass - - def finish(self): - return path - - with patch("whatsapp_chatbot_python.calls.service.CallRecorder", FakeRecorder): - result = await self.make_service().execute(session, transition) - - self.assertEqual(session.end_reason, CallEndReason.ERROR) - self.assertIsNone(result.recording_path) - self.assertFalse(path.exists()) - - async def test_stop_before_execute_does_not_start_a_call(self): - session, transition = self.make_session() - - service = self.make_service() - - service.request_stop() - - result = await service.execute(session, transition) - - self.assertEqual(session.end_reason, CallEndReason.SHUTDOWN) + self.assertFalse(any(isinstance(a, tuple) and a[0] == "dial" for a in self.scenario.actions)) + self.assertTrue(self.apis[0].session_closed) + + async def test_bridge_failure_fails_call_and_finishes_owned_remote(self): + self.scenario.bridge_error = True + self.scenario.remote_hangup = False + session = self.outgoing() + with self.assertRaisesRegex(RuntimeError, "bridge unavailable"): + await self.execute(self.service(), session) + self.assertEqual((session.state, session.end_reason), + (CallState.FAILED, CallEndReason.ERROR)) + self.assertIn("hangup", self.scenario.actions) + self.assertTrue(self.apis[0].session_closed) + + async def test_ring_timeout_hangs_up_owned_outgoing_call(self): + self.scenario.answer = False + self.scenario.remote_hangup = False + session = self.outgoing() + await self.execute(self.service(ring=0.02), session) + self.assertEqual((session.state, session.end_reason), + (CallState.RING_TIMEOUT, CallEndReason.RING_TIMEOUT)) + self.assertIn("hangup", self.scenario.actions) + + async def test_talk_timeout_hangs_up_owned_answered_call(self): + self.scenario.remote_hangup = False + session = self.outgoing() + await self.execute(self.service(talk=0.02), session) + self.assertEqual((session.state, session.end_reason), + (CallState.TALK_TIMEOUT, CallEndReason.TALK_TIMEOUT)) + self.assertIn("hangup", self.scenario.actions) + self.assertEqual(self.voices[0].greetings, [None]) + + async def test_outgoing_must_be_dequeued_before_any_resource_is_created(self): + session = CallSession("sender@c.us", "sender@c.us", "ru") + self.fsm.apply(session, CallEvent.ENQUEUED) + with self.assertRaisesRegex(ValueError, "Dequeue"): + await self.execute(self.service(), session) self.assertEqual(session.state, CallState.FAILED) + self.assertEqual(self.apis, []) + self.assertEqual(self.voices, []) + + async def test_expired_incoming_offer_does_not_start_voice_or_rtc(self): + self.scenario.state = "inc-call" + session = self.incoming(deadline=monotonic() - 1) + await self.execute(self.service(), session) + self.assertEqual((session.state, session.end_reason), + (CallState.REJECTED, CallEndReason.INCOMING_EXPIRED)) + self.assertEqual(self.voices, []) + self.assertNotIn("open", self.scenario.actions) + self.assertIn("reject", self.scenario.actions) + self.assertTrue(self.apis[0].session_closed) + + async def test_already_finished_incoming_session_starts_no_resources(self): + session = self.incoming() + self.fsm.apply(session, CallEvent.CALLER_LEFT) + result = await self.execute(self.service(), session) + self.assertEqual(session.state, CallState.REJECTED) self.assertIsNone(result.recording_path) - self.assertEqual(FakeClient.instances, []) - self.assertEqual(FakeVoice.instances, []) + self.assertEqual(self.apis, []) + self.assertEqual(self.voices, []) + + async def test_other_incoming_call_is_never_accepted_or_ended(self): + self.scenario.state = "inc-call" + self.scenario.call_id = "another-call" + session = self.incoming(call_id="call-1") + await self.execute(self.service(), session) + self.assertEqual(session.state, CallState.REJECTED) + self.assertNotIn("accept", self.scenario.actions) + self.assertNotIn("reject", self.scenario.actions) + self.assertNotIn("hangup", self.scenario.actions) + + async def test_incoming_deadline_interrupts_pending_accept(self): + self.scenario.state = "inc-call" + self.scenario.remote_hangup = False + self.scenario.gates["accept"] = asyncio.Event() + session = self.incoming(deadline=monotonic() + 0.03) + await self.execute(self.service(), session) + self.assertEqual((session.state, session.end_reason), + (CallState.REJECTED, CallEndReason.INCOMING_EXPIRED)) + self.assertIn("reject", self.scenario.actions) + self.assertTrue(self.apis[0].session_closed) + + async def test_matching_answer_can_arrive_before_accept_command_returns(self): + self.scenario.state = "inc-call" + self.scenario.gates["accept"] = asyncio.Event() + session = self.incoming() + task = asyncio.create_task(self.execute(self.service(), session)) + try: + await self.wait_action("accept") + self.apis[0].rtc.set_state("on-call") + await task + self.assertEqual(session.state, CallState.REMOTE_ENDED) + self.assertTrue(session.remote_accepted) + self.assertEqual(self.voices[0].greetings, [None]) + finally: + self.scenario.gates["accept"].set() + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + async def test_new_call_after_answer_is_never_hung_up_by_old_runner(self): + self.scenario.state = "inc-call" + self.scenario.remote_hangup = False + session = self.incoming() + task = asyncio.create_task(self.execute(self.service(talk=0.03), session)) + try: + await self.wait_action("bridge") + self.scenario.call_id = "next-call" + await task + self.assertEqual(session.state, CallState.FAILED) + self.assertEqual(session.error_code, "finish_not_owned") + self.assertNotIn("hangup", self.scenario.actions) + self.assertNotIn("reject", self.scenario.actions) + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + async def test_reject_incoming_only_rejects_matching_unanswered_offer(self): + self.scenario.state = "inc-call" + service = self.service() + self.assertFalse(await service.reject_incoming("another-call", "sender@c.us")) + self.assertTrue(await service.reject_incoming("call-1", "sender@c.us")) + self.assertEqual(self.scenario.actions.count("reject"), 1) + self.assertTrue(all(api.session_closed for api in self.apis)) + + async def test_caller_left_during_pending_accept_wakes_runner(self): + self.scenario.state = "inc-call" + self.scenario.remote_hangup = False + self.scenario.gates["accept"] = asyncio.Event() + service = self.service() + session = self.incoming() + task = asyncio.create_task(self.execute(service, session)) + try: + await self.wait_action("accept") + self.assertFalse(service.notify_incoming_ended("older-call")) + self.assertTrue(service.notify_incoming_ended("call-1")) + await task + self.assertEqual((session.state, session.end_reason), + (CallState.REJECTED, CallEndReason.CALLER_LEFT)) + self.assertFalse(service.notify_incoming_ended("call-1")) + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + async def test_stop_from_other_thread_interrupts_pending_accept(self): + self.scenario.state = "inc-call" + self.scenario.gates["accept"] = asyncio.Event() + service = self.service() + session = self.incoming() + task = asyncio.create_task(self.execute(service, session)) + try: + await self.wait_action("accept") + await asyncio.to_thread(service.request_stop) + await task + self.assertEqual((session.state, session.end_reason), + (CallState.CANCELLED, CallEndReason.SHUTDOWN)) + self.assertTrue(self.apis[0].session_closed) + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + async def test_stopped_service_does_not_start_new_call(self): + service = self.service() + service.request_stop() + session = self.outgoing() + await self.execute(service, session) + self.assertEqual(session.state, CallState.CANCELLED) + self.assertEqual(self.apis, []) + self.assertEqual(self.voices, []) + + async def test_service_runs_successive_calls_with_fresh_resources(self): + service = self.service() + first, second = self.outgoing(), self.outgoing() + await self.execute(service, first) + await self.execute(service, second) + self.assertEqual((first.state, second.state), + (CallState.REMOTE_ENDED, CallState.REMOTE_ENDED)) + self.assertEqual(len(self.apis), 2) + self.assertTrue(all(api.session_closed for api in self.apis)) + + async def test_service_serializes_two_concurrent_calls(self): + self.scenario.gates["dial"] = asyncio.Event() + service = self.service() + first, second = self.outgoing(), self.outgoing() + first_task = asyncio.create_task(self.execute(service, first)) + second_task = None + try: + await self.wait_action(("dial", "sender@c.us")) + second_task = asyncio.create_task(self.execute(service, second)) + await asyncio.sleep(0.005) + self.assertEqual(len(self.apis), 1) + self.scenario.gates["dial"].set() + await asyncio.gather(first_task, second_task) + self.assertEqual((first.state, second.state), + (CallState.REMOTE_ENDED, CallState.REMOTE_ENDED)) + self.assertEqual(len(self.apis), 2) + finally: + self.scenario.gates["dial"].set() + for task in (first_task, second_task): + if task is not None and not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + async def test_caller_left_after_answer_is_remote_hangup(self): + self.scenario.state = "inc-call" + self.scenario.remote_hangup = False + service = self.service() + session = self.incoming() + task = asyncio.create_task(self.execute(service, session)) + try: + await self.wait_action("greet") + self.assertTrue(service.notify_incoming_ended("call-1")) + await task + self.assertEqual((session.state, session.end_reason), + (CallState.REMOTE_ENDED, CallEndReason.REMOTE_HANGUP)) + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + async def test_live_offer_accept_failure_is_not_misreported_as_caller_left(self): + """RED: a failed accept against the same live offer is a provider failure.""" + self.scenario.state = "inc-call" + self.scenario.accept_error = True + session = self.incoming() + with suppress(ProviderError): + await self.execute(self.service(), session) + self.assertEqual((session.state, session.end_reason), + (CallState.FAILED, CallEndReason.ERROR)) diff --git a/tests/calls/test_call_state_machine.py b/tests/calls/test_call_state_machine.py index 78b5eb9..048b0f8 100644 --- a/tests/calls/test_call_state_machine.py +++ b/tests/calls/test_call_state_machine.py @@ -2,12 +2,14 @@ import unittest from whatsapp_chatbot_python.calls.models import ( + CallDirection, CallEndReason, CallEvent, CallSession, CallState, TERMINAL_STATES, ) +from time import monotonic class CallStateMachineTest(unittest.TestCase): @@ -117,7 +119,9 @@ def test_remote_idle_confirms_pending_timeout(self) -> None: ): self.apply(event) - self.assertEqual(self.apply(CallEvent.REMOTE_IDLE), CallState.RING_TIMEOUT) + # The runner confirms the timeout after its remote finish operation. + self.assertEqual(self.apply(CallEvent.REMOTE_IDLE), CallState.ENDING) + self.assertEqual(self.apply(CallEvent.HANGUP_CONFIRMED), CallState.RING_TIMEOUT) def test_late_event_cannot_change_terminal_state(self) -> None: self.test_remote_idle_is_rejected_before_answer() @@ -156,7 +160,7 @@ def test_shutdown_has_explicit_end_reason(self) -> None: error_code="shutdown", ) self.assertTrue(result.applied) - self.assertEqual(self.session.state, CallState.FAILED) + self.assertEqual(self.session.state, CallState.CANCELLED) self.assertEqual(self.session.end_reason, CallEndReason.SHUTDOWN) def test_all_terminal_states_ignore_late_events(self) -> None: @@ -173,6 +177,50 @@ def test_all_terminal_states_ignore_late_events(self) -> None: self.assertFalse(result.applied) self.assertEqual(session.state, state) + def test_incoming_uses_existing_queue_and_answer_transitions(self) -> None: + session = CallSession.incoming( + sender_id="sender@c.us", call_id="call-1", language="ru", + incoming_deadline=monotonic() + 60, + ) + self.assertEqual(session.direction, CallDirection.INCOMING) + self.fsm.apply(session, CallEvent.ENQUEUED) + self.fsm.apply(session, CallEvent.DEQUEUED) + self.assertEqual(session.state, CallState.RINGING) + self.assertIsNotNone(session.started_at) + self.assertIsNone(session.answered_at) + + self.fsm.apply(session, CallEvent.INCOMING_ACCEPTED) + self.assertEqual(session.state, CallState.RINGING) + self.assertTrue(session.incoming_accept_completed) + self.assertIsNone(session.answered_at) + + self.fsm.apply(session, CallEvent.REMOTE_ACCEPTED) + self.assertEqual(session.state, CallState.CONNECTING) + self.fsm.apply(session, CallEvent.BRIDGE_READY) + self.assertEqual(session.state, CallState.IN_CALL) + self.assertIsNotNone(session.answered_at) + + def test_incoming_expiry_and_caller_left_from_queue(self) -> None: + for event, state, reason in ( + (CallEvent.INCOMING_EXPIRED, CallState.REJECTED, CallEndReason.INCOMING_EXPIRED), + (CallEvent.CALLER_LEFT, CallState.REJECTED, CallEndReason.CALLER_LEFT), + ): + with self.subTest(event=event): + session = CallSession.incoming( + sender_id="sender@c.us", call_id="call-1", language="en", + incoming_deadline=monotonic() + 60, + ) + self.fsm.apply(session, CallEvent.ENQUEUED) + self.fsm.apply(session, event) + self.assertEqual((session.state, session.end_reason), (state, reason)) + self.assertFalse(self.fsm.apply(session, CallEvent.DEQUEUED).applied) + + def test_incoming_only_events_do_not_change_outgoing_calls(self) -> None: + self.apply(CallEvent.ENQUEUED) + for event in (CallEvent.INCOMING_EXPIRED, CallEvent.CALLER_LEFT, CallEvent.INCOMING_ACCEPTED): + self.assertFalse(self.fsm.apply(self.session, event).applied) + self.assertEqual(self.session.state, CallState.QUEUED) + if __name__ == "__main__": unittest.main() diff --git a/tests/calls/test_greenapi.py b/tests/calls/test_greenapi.py new file mode 100644 index 0000000..8f73ff4 --- /dev/null +++ b/tests/calls/test_greenapi.py @@ -0,0 +1,157 @@ +"""A call adapter may finish only the call that it owns.""" + +import asyncio +import unittest +from time import monotonic +from unittest.mock import patch + +from whatsapp_chatbot_python.calls.greenapi import GreenApiCall +from whatsapp_chatbot_python.calls.models import ( + CallCleanupError, CallSession, FinishResult, MediaFailed, ProviderConfig, ProviderError, + RemoteAnswered, +) + +from tests.calls.fakes import FakeApi, Scenario +from tests.calls.fakes import FakeRtc + + +class GreenApiCallTest(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.scenario = Scenario(remote_hangup=False) + self.apis = [] + self.events = [] + + def create_api(*args, **kwargs): + api = FakeApi(self.scenario, *args, **kwargs) + self.apis.append(api) + return api + + patcher = patch("whatsapp_api_client_python.API.GreenApi", create_api) + patcher.start() + self.addCleanup(patcher.stop) + self.config = ProviderConfig("https://example.invalid", "1", "token", "key", + "model", "voice") + + async def start_outgoing(self): + call = GreenApiCall(self.config, + CallSession("sender@c.us", "sender@c.us", "ru"), + self.events.append, 0.1) + await call.__aenter__() + await call.open(type("Audio", (), {"create": lambda self: None})()) + await call.dial() + await asyncio.sleep(0) + return call + + async def test_owned_outgoing_call_is_hung_up_and_http_session_closed(self): + call = await self.start_outgoing() + try: + self.assertEqual(await call.finish(), FinishResult.FINISHED) + self.assertIn("hangup", self.scenario.actions) + self.assertTrue(any(isinstance(event, RemoteAnswered) for event in self.events)) + finally: + await call.__aexit__(None, None, None) + self.assertTrue(self.apis[0].session_closed) + self.assertTrue(self.apis[0].rtc.closed) + + async def test_other_call_is_not_hung_up(self): + call = await self.start_outgoing() + try: + self.scenario.call_id = "new-call" + self.assertEqual(await call.finish(), FinishResult.NOT_OWNED) + self.assertNotIn("hangup", self.scenario.actions) + self.assertNotIn("reject", self.scenario.actions) + finally: + await call.__aexit__(None, None, None) + + async def test_remote_ended_call_is_not_hung_up_twice(self): + call = await self.start_outgoing() + try: + self.apis[0].rtc.set_state("idle", "hangup") + self.assertEqual(await call.finish(), FinishResult.ALREADY_ENDED) + self.assertNotIn("hangup", self.scenario.actions) + finally: + await call.__aexit__(None, None, None) + + async def test_temporary_disconnect_is_recoverable_but_permanent_is_reported(self): + call = await self.start_outgoing() + try: + self.apis[0].rtc.emit("disconnect", {"permanent": False}) + self.assertFalse(any(isinstance(event, MediaFailed) for event in self.events)) + self.apis[0].rtc.emit("disconnect", {"permanent": True}) + self.assertEqual(sum(isinstance(event, MediaFailed) for event in self.events), 1) + finally: + await call.__aexit__(None, None, None) + + async def test_reject_offer_requires_matching_unanswered_call(self): + self.scenario.state = "inc-call" + session = CallSession.incoming(sender_id="sender@c.us", call_id="call-1", + language="ru", incoming_deadline=monotonic() + 1) + call = GreenApiCall(self.config, session, self.events.append, 0.1) + async with call: + self.assertTrue(await call.reject_offer()) + self.assertIn("reject", self.scenario.actions) + self.assertTrue(self.apis[0].session_closed) + + self.scenario.actions.clear() + self.scenario.state = "on-call" + async with GreenApiCall(self.config, session, self.events.append, 0.1) as second: + self.assertFalse(await second.reject_offer()) + self.assertNotIn("reject", self.scenario.actions) + + async def test_provider_error_retains_details_for_caller_without_exposing_token(self): + self.scenario.state = "inc-call" + self.scenario.accept_error = True + session = CallSession.incoming(sender_id="sender@c.us", call_id="call-1", + language="ru", incoming_deadline=monotonic() + 1) + async with GreenApiCall(self.config, session, self.events.append, 0.1) as call: + with self.assertRaises(ProviderError) as raised: + await call.accept() + self.assertEqual(raised.exception.code, "accept_RuntimeError") + self.assertIn("accept unavailable", raised.exception.detail) + self.assertNotIn("token", str(raised.exception)) + + async def test_rtc_timeout_does_not_cancel_sdk_close(self): + call = await self.start_outgoing() + release = asyncio.Event() + + async def delayed_close(rtc): + await release.wait() + rtc.closed = True + + try: + with patch.object(FakeRtc, "closeAsync", delayed_close): + with self.assertRaises(CallCleanupError) as raised: + await call.__aexit__(None, None, None) + + self.assertEqual(raised.exception.stage, "rtc_close") + self.assertTrue(self.apis[0].session_closed) + self.assertFalse(self.apis[0].rtc.closed) + finally: + release.set() + await asyncio.wait_for(call._rtc_close_task, 0.5) + + self.assertTrue(self.apis[0].rtc.closed) + + async def test_late_rtc_open_cannot_restart_closed_adapter(self): + started, release = asyncio.Event(), asyncio.Event() + call = GreenApiCall( + self.config, CallSession("sender@c.us", "sender@c.us", "ru"), + self.events.append, 0.1, + ) + await call.__aenter__() + + async def delayed_open(rtc, *, timeout): + started.set() + await release.wait() + + with patch.object(FakeRtc, "openAsync", delayed_open): + opening = asyncio.create_task(call.open(type("Audio", (), {"create": lambda self: None})())) + await started.wait() + await call.__aexit__(None, None, None) + release.set() + + with self.assertRaisesRegex(RuntimeError, "closing"): + await opening + + self.assertTrue(self.apis[0].rtc.closed) + self.assertTrue(self.apis[0].session_closed) diff --git a/tests/calls/test_playback_state_machine.py b/tests/calls/test_playback_state_machine.py index 0aa1ab0..57043cb 100644 --- a/tests/calls/test_playback_state_machine.py +++ b/tests/calls/test_playback_state_machine.py @@ -1,43 +1,86 @@ -import unittest +"""Playback policy at the PCM boundary, including deliberate red guarantees.""" +from unittest import TestCase + +from whatsapp_chatbot_python.calls.models import PCM, PlaybackOptions from whatsapp_chatbot_python.calls.playback_state_machine import ( - PlaybackEvent, - PlaybackState, - PlaybackStateMachine, + AppendResult, PlaybackController, PlaybackEvent, PlaybackState, PlaybackStateMachine, ) -class PlaybackStateMachineTest(unittest.TestCase): - def test_allowed_transitions(self): - fsm = PlaybackStateMachine() +FRAME = bytes([1, 2]) * PCM.samples_per_frame + + +class PlaybackStateMachineTest(TestCase): + def test_transitions_are_stateful_and_duplicate_events_are_safe(self): + machine = PlaybackStateMachine() + self.assertEqual(machine.apply(PlaybackEvent.SOFT_LIMIT), PlaybackState.BACKLOG) + self.assertEqual(machine.apply(PlaybackEvent.SOFT_LIMIT), PlaybackState.BACKLOG) + self.assertEqual(machine.apply(PlaybackEvent.RECOVERED), PlaybackState.PLAYING) + self.assertEqual(machine.apply(PlaybackEvent.HARD_LIMIT), PlaybackState.DRAINING) + self.assertEqual(machine.apply(PlaybackEvent.DRAINED), PlaybackState.PLAYING) + self.assertEqual(machine.apply(PlaybackEvent.STALLED), PlaybackState.SUSPENDED) + self.assertEqual(machine.apply(PlaybackEvent.RESUMED), PlaybackState.PLAYING) + - transitions = [ - (PlaybackState.PLAYING, PlaybackEvent.SOFT_LIMIT, PlaybackState.BACKLOG), - (PlaybackState.BACKLOG, PlaybackEvent.RECOVERED, PlaybackState.PLAYING), - (PlaybackState.PLAYING, PlaybackEvent.HARD_LIMIT, PlaybackState.DRAINING), - (PlaybackState.BACKLOG, PlaybackEvent.HARD_LIMIT, PlaybackState.DRAINING), - (PlaybackState.DRAINING, PlaybackEvent.DRAINED, PlaybackState.PLAYING), - (PlaybackState.SUSPENDED, PlaybackEvent.RESUMED, PlaybackState.PLAYING), - ] +class PlaybackControllerTest(TestCase): + def make_controller(self): + return PlaybackController(PlaybackOptions( + soft_limit_seconds=PCM.frame_seconds, + hard_limit_seconds=PCM.frame_seconds * 2, + )) - for state in (PlaybackState.PLAYING, PlaybackState.BACKLOG, PlaybackState.DRAINING): - transitions.extend([ - (state, PlaybackEvent.STALLED, PlaybackState.SUSPENDED), - (state, PlaybackEvent.INTERRUPTED, PlaybackState.PLAYING), - ]) + def test_soft_backlog_recovers_after_consumption(self): + playback = self.make_controller() + self.assertEqual(playback.append("item", 0, FRAME), AppendResult.ACCEPTED) + self.assertEqual(playback.state, PlaybackState.BACKLOG) + self.assertEqual(playback.read_frame(), FRAME) + self.assertEqual(playback.state, PlaybackState.PLAYING) - for state in PlaybackState: - transitions.append((state, PlaybackEvent.TRACK_REPLACED, PlaybackState.PLAYING)) + def test_barge_in_discards_unheard_audio_and_reports_played_duration(self): + playback = self.make_controller() + self.assertEqual(playback.append("item", 0, FRAME * 2), AppendResult.ACCEPTED) + self.assertEqual(playback.read_frame(), FRAME) + self.assertEqual([(c.item_id, c.content_index, c.played_ms) + for c in playback.interrupt()], [("item", 0, PCM.frame_ms)]) + self.assertEqual(playback.read_frame(), bytes(PCM.bytes_per_frame)) - for previous, event, expected in transitions: - with self.subTest(previous=previous, event=event): - self.assertEqual(fsm.apply(previous, event), expected) + def test_partial_final_frame_is_padded_but_played_duration_is_exact(self): + playback = self.make_controller() + partial = FRAME[:100] + self.assertEqual(playback.append("item", 0, partial), AppendResult.ACCEPTED) + playback.finish_response() + self.assertEqual(playback.read_frame(), partial + bytes(PCM.bytes_per_frame - 100)) + self.assertEqual(playback.interrupt(), []) - def test_duplicate_and_out_of_order_events_are_ignored(self): - fsm = PlaybackStateMachine() + def test_track_replacement_discards_old_buffer_and_reports_cut(self): + playback = self.make_controller() + playback.append("old-item", 2, FRAME * 2) + self.assertEqual(playback.read_frame(), FRAME) + cut = playback.replace_track() + self.assertEqual([(c.item_id, c.content_index, c.played_ms) for c in cut], + [("old-item", 2, PCM.frame_ms)]) + self.assertEqual(playback.read_frame(), bytes(PCM.bytes_per_frame)) + self.assertEqual(playback.append("new-item", 0, FRAME), AppendResult.ACCEPTED) + self.assertEqual(playback.read_frame(), FRAME) - self.assertEqual(fsm.apply(PlaybackState.DRAINING, PlaybackEvent.SOFT_LIMIT), - PlaybackState.DRAINING) + def test_hard_limit_drains_already_accepted_frames_before_truncation(self): + """A slow caller must still hear audio that the bot already accepted.""" + playback = self.make_controller() + self.assertEqual(playback.append("item", 0, FRAME), AppendResult.ACCEPTED) + self.assertEqual(playback.append("item", 0, FRAME), AppendResult.ACCEPTED) + self.assertEqual(playback.append("item", 0, FRAME), AppendResult.OVERFLOW) + self.assertEqual(playback.state, PlaybackState.DRAINING) + self.assertEqual(playback.read_frame(), FRAME) + self.assertEqual(playback.read_frame(), FRAME) + self.assertEqual(playback.state, PlaybackState.PLAYING) + self.assertEqual(playback.read_frame(), bytes(PCM.bytes_per_frame)) - self.assertEqual(fsm.apply(PlaybackState.SUSPENDED, PlaybackEvent.DRAINED), - PlaybackState.SUSPENDED) + def test_completed_item_counters_are_released_after_playback(self): + playback = self.make_controller() + playback.append("item", 0, FRAME) + playback.complete_item("item", 0) + self.assertIn(("item", 0), playback._received) + self.assertEqual(playback.read_frame(), FRAME) + self.assertNotIn(("item", 0), playback._received) + self.assertNotIn(("item", 0), playback._played) diff --git a/tests/calls/test_realtime_voice.py b/tests/calls/test_realtime_voice.py index c9f1401..85cd914 100644 --- a/tests/calls/test_realtime_voice.py +++ b/tests/calls/test_realtime_voice.py @@ -1,28 +1,25 @@ -from fractions import Fraction -from types import SimpleNamespace -from unittest.mock import ANY, patch -from whatsapp_chatbot_python.calls.playback_state_machine import PlaybackState +"""Realtime transport tests keep OpenAI external and exercise actual playback.""" + import asyncio import base64 import unittest -import av - -from whatsapp_chatbot_python.calls.realtime_voice import ( - BotOutputTrack, - CallerAudioSink, - VoiceBotSession, - FRAME_BYTES, - FRAME_DURATION_MS, - HARD_PLAYBACK_BUFFER_MS, - SOFT_PLAYBACK_BUFFER_MS, - RECOVER_PLAYBACK_BUFFER_MS, +from types import SimpleNamespace +from unittest.mock import patch + +from whatsapp_chatbot_python.calls.models import ( + MediaFailed, PCM, PlaybackOptions, ProviderConfig, ) +from whatsapp_chatbot_python.calls.openai import RealtimeConversation + + +FRAME = bytes([1, 2]) * PCM.samples_per_frame class FakeConnection: def __init__(self): self.events = asyncio.Queue() self.actions = [] + self.closed = False self.session = SimpleNamespace(update=self.update) self.response = SimpleNamespace(create=self.create, cancel=self.cancel) self.input_audio_buffer = SimpleNamespace(append=self.append) @@ -33,7 +30,7 @@ async def update(self, **kwargs): await self.events.put(SimpleNamespace(type="session.updated")) async def create(self, **kwargs): - self.actions.append(("greet", kwargs)) + self.actions.append(("create", kwargs)) async def cancel(self, **kwargs): self.actions.append(("cancel", kwargs)) @@ -44,412 +41,176 @@ async def append(self, **kwargs): async def truncate(self, **kwargs): self.actions.append(("truncate", kwargs)) - async def recv(self): - return await self.events.get() - - async def __aiter__(self): - while True: - yield await self.recv() - - -class FakeManager: - def __init__(self, conn): - self.conn = conn - self.closed = False + async def close(self): + self.closed = True - async def __aenter__(self): - return self.conn + def __aiter__(self): + return self - async def __aexit__(self, *args): - self.closed = True + async def __anext__(self): + return await self.events.get() class FakeOpenAI: - def __init__(self, api_key): + def __init__(self, *, api_key): self.api_key = api_key self.connection = FakeConnection() - self.manager = FakeManager(self.connection) - self.realtime = SimpleNamespace(connect=lambda **kwargs: self.manager) self.closed = False + self.realtime = SimpleNamespace(connect=lambda **kwargs: self) + + async def enter(self): + return self.connection async def close(self): self.closed = True -class OneFrameTrack: - kind = "audio" - - def __init__(self): - self.sent = False +class RealtimeConversationTest(unittest.IsolatedAsyncioTestCase): + async def test_late_connection_is_closed_after_owner_stops(self): + # A provider may finish enter() after the operation was cancelled. + started, release = asyncio.Event(), asyncio.Event() + second = RealtimeConversation( + ProviderConfig("https://example.invalid", "1", "token", "key", "model", "marin"), + PlaybackOptions(), self.failures.append, "ru", 0.1, + ) + await second.__aenter__() - async def recv(self): - if self.sent: - await asyncio.Future() + async def delayed_enter(): + started.set() + await release.wait() + return second._client.connection - self.sent = True - frame = av.AudioFrame(format="s16", layout="mono", samples=480) - frame.planes[0].update(bytes(FRAME_BYTES)) - frame.sample_rate = 24_000 - frame.pts = 0 - frame.time_base = Fraction(1, 24_000) + with patch.object(second._client, "enter", delayed_enter): + opening = asyncio.create_task(second.start()) + await started.wait() + await second.__aexit__(None, None, None) + release.set() - return frame + with self.assertRaisesRegex(RuntimeError, "closing"): + await opening + self.assertTrue(second._client.connection.closed) + self.assertFalse(second._tasks) -class VoiceSessionTest(unittest.IsolatedAsyncioTestCase): - def setUp(self): + async def asyncSetUp(self): self.client = None + self.failures = [] - def make_client(**kwargs): + def create_client(**kwargs): self.client = FakeOpenAI(**kwargs) return self.client - patcher = patch("whatsapp_chatbot_python.calls.realtime_voice.AsyncOpenAI", make_client) - - patcher.start() - self.addCleanup(patcher.stop) - self.failures = [] - - self.voice = VoiceBotSession( - api_key="test", model="gpt-realtime-2.1", voice="marin", - language="ru", on_error=self.failures.append, + self.patcher = patch("openai.AsyncOpenAI", create_client) + self.patcher.start() + self.addCleanup(self.patcher.stop) + self.voice = RealtimeConversation( + ProviderConfig("https://example.invalid", "1", "token", "explicit-key", + "realtime-test", "marin", None), + PlaybackOptions(soft_limit_seconds=PCM.frame_seconds, + hard_limit_seconds=PCM.frame_seconds * 2, + watchdog_seconds=0.01), + self.failures.append, "ru", 0.1, ) - - async def asyncTearDown(self): - await self.voice.close() - - async def test_session_configuration_and_single_greeting(self): + await self.voice.__aenter__() await self.voice.start() - await self.voice.greet_once() - await self.voice.greet_once() - - actions = self.client.connection.actions - - self.assertEqual([a for a, _ in actions], ["update", "greet"]) - self.assertEqual(actions[0][1]["session"]["audio"]["input"]["format"]["rate"], 24_000) - async def test_supported_languages_configure_voice_and_greeting(self): - for code, language in { - "ru": "Russian", "en": "English", "he": "Hebrew", - "es": "Spanish", "kz": "Kazakh", - }.items(): - with self.subTest(code=code): - voice = VoiceBotSession( - api_key="test", model="gpt-realtime-2.1", voice="marin", - language=code, on_error=self.failures.append, - ) - - try: - await voice.start() - await voice.greet_once() - - actions = self.client.connection.actions - - self.assertIn(language, actions[0][1]["session"]["instructions"]) - self.assertIn(language, actions[1][1]["response"]["instructions"]) - finally: - await voice.close() - - async def test_audio_frames_are_paced_and_timestamped(self): - await self.voice.start() - - track = await self.voice.new_output_track() - - self.voice._queue_audio("item-1", bytes([1, 2]) * 480) - - first = await track.recv() - second = await track.recv() - - self.assertEqual(first.pts, 0) - self.assertEqual(second.pts, 480) - self.assertEqual(bytes(first.planes[0])[:FRAME_BYTES], bytes([1, 2]) * 480) - self.assertEqual(bytes(second.planes[0])[:FRAME_BYTES], bytes(FRAME_BYTES)) - self.assertEqual(track._voice._played["item-1"], 480) - - async def test_barge_in_clears_audio_and_truncates_played_part(self): - await self.voice.start() - - track = await self.voice.new_output_track() - - self.voice._active_responses.add("response-1") - self.voice._item_response["item-1"] = "response-1" - self.voice._queue_audio("item-1", bytes(FRAME_BYTES * 3)) - await track.recv() - await self.voice._interrupt(cancel=True) - self.assertEqual(len(self.voice._frames), 0) - - self.assertIn(("cancel", {"response_id": "response-1", "event_id": ANY}), - self.client.connection.actions) - self.assertIn(("truncate", { - "item_id": "item-1", "content_index": 0, "audio_end_ms": 20, - }), self.client.connection.actions) - - self.voice._queue_audio("item-1", bytes(FRAME_BYTES)) - self.assertEqual(len(self.voice._frames), 0) - - async def test_backlog_recovers_without_cancelling_generation(self): - await self.voice.start() - track = await self.voice.new_output_track() - self.voice._queue_audio("item-1", bytes( - FRAME_BYTES * (SOFT_PLAYBACK_BUFFER_MS // FRAME_DURATION_MS) + async def asyncTearDown(self): + await self.voice.__aexit__(None, None, None) + + async def dispatch(self, event): + await self.client.connection.events.put(SimpleNamespace(**event)) + for _ in range(100): + if self.client.connection.events.empty(): + await asyncio.sleep(0) + return + await asyncio.sleep(0.001) + self.fail("Realtime reader did not consume event") + + async def audio(self, payload=FRAME, response="response-1", item="item-1"): + await self.dispatch(dict( + type="response.output_audio.delta", response_id=response, + item_id=item, content_index=0, + delta=base64.b64encode(payload).decode("ascii"), )) - self.assertEqual(self.voice.playback_state, PlaybackState.BACKLOG) - - while len(self.voice._frames) * FRAME_DURATION_MS > RECOVER_PLAYBACK_BUFFER_MS: - self.voice.take_frame(track._generation) - - self.assertEqual(self.voice.playback_state, PlaybackState.PLAYING) - self.assertFalse(any(action == "cancel" for action, _ in self.client.connection.actions)) - - async def test_full_buffer_drains_accepted_audio_before_truncating(self): - await self.voice.start() - old = await self.voice.new_output_track() - await self.client.connection.events.put(SimpleNamespace( - type="response.created", response=SimpleNamespace(id="response-1") - )) - await self.client.connection.events.put(SimpleNamespace( - type="response.output_audio.delta", response_id="response-1", - item_id="item-1", content_index=0, - delta=base64.b64encode( - bytes([1, 2]) * (FRAME_BYTES // 2) - * (HARD_PLAYBACK_BUFFER_MS // FRAME_DURATION_MS + 1) - ).decode(), - )) + async def test_session_uses_explicit_key_and_greets_only_once(self): + self.assertEqual(self.client.api_key, "explicit-key") + settings = self.client.connection.actions[0][1]["session"] + self.assertEqual(settings["audio"]["output"]["voice"], "marin") + self.assertEqual(settings["audio"]["input"]["format"]["rate"], PCM.rate) + await self.voice.greet_once("Привет") + await self.voice.greet_once("Ещё раз") + self.assertEqual([action for action, _ in self.client.connection.actions].count("create"), 1) + self.assertEqual( + self.client.connection.actions[-1][1]["response"]["instructions"], + 'Greet the caller: "Привет" in Russian.', + ) - for _ in range(20): - if any(action == "cancel" for action, _ in self.client.connection.actions): - break - await asyncio.sleep(0.01) + async def test_default_greeting_instruction_uses_language_name(self): + await self.voice.greet_once(None) + self.assertEqual( + self.client.connection.actions[-1][1]["response"]["instructions"], + "Greet the caller briefly in Russian.", + ) + async def test_incoming_pcm_is_base64_encoded(self): + await self.voice.write_audio(FRAME) + self.assertEqual(self.client.connection.actions[-1], + ("append", {"audio": base64.b64encode(FRAME).decode("ascii")})) + + async def test_barge_in_truncates_to_audio_actually_heard(self): + await self.dispatch(dict(type="response.created", response=SimpleNamespace(id="response-1"))) + await self.audio(FRAME * 2) + self.assertEqual(self.voice.read_frame(), FRAME) + await self.dispatch(dict(type="input_audio_buffer.speech_started")) + self.assertEqual(self.voice.read_frame(), bytes(PCM.bytes_per_frame)) actions = self.client.connection.actions - self.assertEqual(actions[-1][0], "cancel") - self.assertEqual(actions[-1][1]["response_id"], "response-1") - self.assertEqual(self.voice.playback_state, PlaybackState.DRAINING) - self.assertEqual(len(self.voice._frames) * FRAME_DURATION_MS, HARD_PLAYBACK_BUFFER_MS) - self.assertEqual(self.failures, []) - - await self.client.connection.events.put(SimpleNamespace( - type="response.output_audio.delta", response_id="response-1", - item_id="item-1", content_index=0, - delta=base64.b64encode(bytes(FRAME_BYTES)).decode(), - )) - await asyncio.sleep(0) - self.assertEqual(len(self.voice._frames) * FRAME_DURATION_MS, HARD_PLAYBACK_BUFFER_MS) - - for _ in range(HARD_PLAYBACK_BUFFER_MS // FRAME_DURATION_MS): - self.assertEqual(self.voice.take_frame(old._generation), bytes([1, 2]) * 480) - - for _ in range(20): - if any(action == "truncate" for action, _ in self.client.connection.actions): - break - await asyncio.sleep(0.01) - - self.assertEqual(self.voice.playback_state, PlaybackState.PLAYING) - self.assertEqual(self.client.connection.actions[-1], ("truncate", { - "item_id": "item-1", "content_index": 0, - "audio_end_ms": HARD_PLAYBACK_BUFFER_MS, - })) - - new = await self.voice.new_output_track() - - self.assertIsInstance(new, BotOutputTrack) - self.assertEqual(self.voice.take_frame(old._generation), bytes(FRAME_BYTES)) - self.assertEqual(len(self.voice._frames), 0) - - await self.client.connection.events.put(SimpleNamespace( - type="response.created", response=SimpleNamespace(id="response-2") - )) - await self.client.connection.events.put(SimpleNamespace( - type="response.output_audio.delta", response_id="response-2", - item_id="item-2", content_index=0, - delta=base64.b64encode(bytes([1, 2]) * 480).decode(), - )) - for _ in range(20): - if self.voice._frames: - break - await asyncio.sleep(0.01) - self.assertEqual(bytes((await new.recv()).planes[0])[:FRAME_BYTES], bytes([1, 2]) * 480) - self.assertEqual(self.failures, []) - - async def test_barge_in_during_drain_discards_pending_audio(self): - await self.voice.start() - track = await self.voice.new_output_track() - await self.client.connection.events.put(SimpleNamespace( - type="response.created", response=SimpleNamespace(id="response-1") - )) - await self.client.connection.events.put(SimpleNamespace( - type="response.output_audio.delta", response_id="response-1", - item_id="item-1", content_index=0, - delta=base64.b64encode(bytes( - FRAME_BYTES * (HARD_PLAYBACK_BUFFER_MS // FRAME_DURATION_MS + 1) - )).decode(), - )) - for _ in range(20): - if self.voice.playback_state == PlaybackState.DRAINING: - break - await asyncio.sleep(0.01) - - self.voice.take_frame(track._generation) - await self.client.connection.events.put(SimpleNamespace( - type="input_audio_buffer.speech_started" - )) - for _ in range(20): - if any(action == "truncate" for action, _ in self.client.connection.actions): - break - await asyncio.sleep(0.01) - - self.assertEqual(self.voice.playback_state, PlaybackState.PLAYING) - self.assertEqual(len(self.voice._frames), 0) - self.assertEqual(self.client.connection.actions[-1][1]["audio_end_ms"], FRAME_DURATION_MS) - self.assertEqual(self.failures, []) - - async def test_track_replacement_truncates_old_playback(self): - await self.voice.start() - old = await self.voice.new_output_track() - await self.client.connection.events.put(SimpleNamespace( - type="response.created", response=SimpleNamespace(id="response-1") - )) - await self.client.connection.events.put(SimpleNamespace( - type="response.output_audio.delta", response_id="response-1", - item_id="item-1", content_index=0, - delta=base64.b64encode(bytes(FRAME_BYTES * 3)).decode(), - )) - for _ in range(20): - if self.voice._frames: - break - await asyncio.sleep(0.01) - - self.voice.take_frame(old._generation) - new = await self.voice.new_output_track() - - self.assertEqual(self.voice.take_frame(old._generation), bytes(FRAME_BYTES)) - self.assertEqual(len(self.voice._frames), 0) - self.assertEqual(self.voice.playback_state, PlaybackState.PLAYING) - self.assertIn(("truncate", { - "item_id": "item-1", "content_index": 0, - "audio_end_ms": FRAME_DURATION_MS, - }), self.client.connection.actions) - self.assertIsInstance(new, BotOutputTrack) - self.assertEqual(self.failures, []) - - async def test_stalled_consumer_is_recovered_without_failing_call(self): - await self.voice.start() - track = await self.voice.new_output_track() - await self.client.connection.events.put(SimpleNamespace( - type="response.created", response=SimpleNamespace(id="response-1") - )) - await self.client.connection.events.put(SimpleNamespace( - type="response.output_audio.delta", response_id="response-1", - item_id="item-1", content_index=0, - delta=base64.b64encode(bytes(FRAME_BYTES * 200)).decode(), - )) - - # Advance only the playback clock; keep the production watchdog interval intact. - for _ in range(20): - if self.voice._buffered_since is not None: + self.assertEqual([name for name, _ in actions[-2:]], ["cancel", "truncate"]) + self.assertEqual(actions[-1][1], dict(item_id="item-1", content_index=0, + audio_end_ms=PCM.frame_ms)) + + async def test_replaced_track_drops_old_audio_and_new_response_plays(self): + await self.dispatch(dict(type="response.created", response=SimpleNamespace(id="response-1"))) + await self.audio(FRAME * 2) + self.assertEqual(self.voice.read_frame(), FRAME) + self.voice.replace_track() + for _ in range(100): + if any(name == "truncate" for name, _ in self.client.connection.actions): break - await asyncio.sleep(0.01) - self.assertIsNotNone(self.voice._buffered_since) - self.voice._buffered_since -= SOFT_PLAYBACK_BUFFER_MS / 1000 + 1 - await asyncio.sleep(0.55) - - self.assertEqual([action for action, _ in self.client.connection.actions[-2:]], - ["cancel", "truncate"]) - self.assertEqual(len(self.voice._frames), 0) - self.assertEqual(self.voice.playback_state, PlaybackState.SUSPENDED) - self.assertEqual(self.voice.take_frame(track._generation), bytes(FRAME_BYTES)) - self.assertEqual(self.voice.playback_state, PlaybackState.PLAYING) - self.assertEqual(self.failures, []) - - async def test_inbound_audio_is_sent_as_base64_pcm(self): - await self.voice.start() - await self.voice.new_output_track() - - sink = CallerAudioSink(self.voice, self.voice.generation) - - await sink.attach(OneFrameTrack()) - - for _ in range(20): - if any(action == "append" for action, _ in self.client.connection.actions): - break - - await asyncio.sleep(0.01) - - await sink.close() - - appended = [data for action, data in self.client.connection.actions if action == "append"] - - self.assertTrue(appended) - self.assertEqual(len(base64.b64decode(appended[0]["audio"])), FRAME_BYTES) + await asyncio.sleep(0.001) + self.assertEqual(self.voice.read_frame(), bytes(PCM.bytes_per_frame)) + self.assertEqual(self.client.connection.actions[-1][1]["audio_end_ms"], PCM.frame_ms) + await self.dispatch(dict(type="response.created", response=SimpleNamespace(id="response-2"))) + await self.audio(FRAME, response="response-2", item="item-2") + self.assertEqual(self.voice.read_frame(), FRAME) + + async def test_hard_buffer_limit_drains_accepted_audio(self): + """RED: cancellation must not mute two already accepted PCM frames.""" + await self.dispatch(dict(type="response.created", response=SimpleNamespace(id="response-1"))) + await self.audio(FRAME) + await self.audio(FRAME) + await self.audio(FRAME) + self.assertIn("cancel", [name for name, _ in self.client.connection.actions]) + self.assertEqual(self.voice.read_frame(), FRAME) + self.assertEqual(self.voice.read_frame(), FRAME) self.assertEqual(self.failures, []) - async def test_recording_taps_tracks_and_ignores_old_bridge(self): - class Recorder: - def __init__(self): - self.generation = 0 - self.caller = [] - self.bot = [] - - def set_generation(self, generation): - self.generation = generation - - def add_caller(self, pcm, generation): - if generation == self.generation: - self.caller.append(pcm) - - def add_bot(self, pcm, generation): - if generation == self.generation: - self.bot.append(pcm) - - recorder = Recorder() - - voice = VoiceBotSession( - api_key="test", - model="gpt-realtime-2.1", - voice="marin", - language="ru", - on_error=self.failures.append, - recorder=recorder, - ) - - try: - await voice.start() - - old_track = await voice.new_output_track() - sink = voice.new_input_sink() - - await sink.attach(OneFrameTrack()) - - for _ in range(20): - if recorder.caller: - break - - await asyncio.sleep(0.01) - - await sink.close() - voice._queue_audio("item-1", bytes([1, 2]) * 480) - await old_track.recv() - self.assertEqual(recorder.caller, [bytes(FRAME_BYTES)]) - self.assertEqual(recorder.bot, [bytes([1, 2]) * 480]) - await voice.new_output_track() - await old_track.recv() - self.assertEqual(len(recorder.bot), 1) - finally: - await voice.close() - - async def test_failed_realtime_response_reports_call_error(self): - await self.voice.start() - - await self.client.connection.events.put(SimpleNamespace( - type="response.done", response=SimpleNamespace(status="failed") - )) - - for _ in range(20): + async def test_provider_response_failure_is_reported(self): + await self.dispatch(dict(type="response.created", response=SimpleNamespace(id="response-1"))) + await self.dispatch(dict(type="response.done", response=SimpleNamespace( + id="response-1", status="failed"))) + for _ in range(100): if self.failures: break - - await asyncio.sleep(0.01) - + await asyncio.sleep(0.001) self.assertEqual(len(self.failures), 1) - self.assertIsInstance(self.failures[0], RuntimeError) + self.assertIsInstance(self.failures[0], MediaFailed) + self.assertEqual(self.failures[0].error.code, "response_failed") + + async def test_finished_cancelled_response_releases_tracking(self): + await self.dispatch(dict(type="response.created", response=SimpleNamespace(id="response-1"))) + await self.voice._cancel_responses() + self.assertIn("response-1", self.voice._cancelled) + await self.dispatch(dict(type="response.done", response=SimpleNamespace( + id="response-1", status="cancelled"))) + self.assertNotIn("response-1", self.voice._cancelled) diff --git a/tests/test_calls_imports.py b/tests/test_calls_imports.py index 7c0a413..5d2c713 100644 --- a/tests/test_calls_imports.py +++ b/tests/test_calls_imports.py @@ -23,18 +23,20 @@ def find_spec(self, fullname, path=None, target=None): raise ImportError("optional dependency blocked: " + fullname) sys.meta_path.insert(0, BlockOptionalImports()) -from whatsapp_chatbot_python import GreenAPIBot, Notification +from whatsapp_chatbot_python import GreenAPIBot, IncomingCall, IncomingCallNotification, Notification +assert IncomingCallNotification.__name__ == "IncomingCallNotification" assert "whatsapp_chatbot_python.calls" not in sys.modules ''') @unittest.skipIf(sys.version_info < (3, 11), "call contracts require Python 3.11+") - def test_contracts_do_not_load_call_implementation(self): + def test_public_call_import_does_not_load_optional_media_dependencies(self): self.run_script(''' import sys -from whatsapp_chatbot_python.calls import CallSession, CallStateMachine +from whatsapp_chatbot_python.calls import CallDirection, CallSession, CallStateMachine, IncomingCallExecutor assert CallSession("sender", "chat", "en").state == "triggered" -assert "whatsapp_chatbot_python.calls.service" not in sys.modules -assert "whatsapp_chatbot_python.calls.realtime_voice" not in sys.modules +assert CallSession("sender", "chat", "en").direction == CallDirection.OUTGOING +assert IncomingCallExecutor.__name__ == "IncomingCallExecutor" +assert "whatsapp_chatbot_python.calls.service" in sys.modules assert "openai" not in sys.modules assert "aiortc" not in sys.modules assert "av" not in sys.modules diff --git a/tests/test_incoming_call_notifications.py b/tests/test_incoming_call_notifications.py new file mode 100644 index 0000000..61b3265 --- /dev/null +++ b/tests/test_incoming_call_notifications.py @@ -0,0 +1,112 @@ +import logging +import unittest +from unittest.mock import Mock + +from whatsapp_chatbot_python import IncomingCall, IncomingCallNotification, Notification +from whatsapp_chatbot_python.manager.router import Router + + +class IncomingCallNotificationTest(unittest.TestCase): + def setUp(self): + self.api = Mock() + self.router = Router(self.api, logging.getLogger(__name__)) + + @staticmethod + def event(status="offer", timestamp=1_700_000_000): + return { + "typeWebhook": "incomingCall", "from": "caller@c.us", + "idMessage": "call-1", "status": status, "timestamp": timestamp, + } + + def test_typed_notification_keeps_original_webhook_and_message_methods_work(self): + received = [] + self.router.incoming_call.add_handler(received.append) + event = self.event(status="future-status") + + self.router.route_event(event) + + notification = received[0] + self.assertIsInstance(notification, IncomingCallNotification) + self.assertIsInstance(notification, Notification) + self.assertIs(notification.event, event) + self.assertIs(notification.raw_event, event) + self.assertIs(notification.call.raw_event, event) + self.assertEqual(notification.call, IncomingCall("caller@c.us", "call-1", "future-status", 1_700_000_000.0)) + self.assertEqual(notification.call.chat_id, "caller@c.us") + self.assertEqual((notification.sender, notification.chat, notification.get_id_message()), + ("caller@c.us", "caller@c.us", "call-1")) + self.assertIsNone(notification.message_text) + self.assertIsNone(notification.get_message_data()) + + notification.answer("Hello") + self.api.sending.sendMessage.assert_called_once_with( + "caller@c.us", "Hello", None, None, None, None, + ) + + def test_call_and_message_observers_share_state_only_with_each_other(self): + state_manager = self.router.message.state_manager + self.assertIs(self.router.incoming_call.state_manager, state_manager) + self.assertIsNot(self.router.outgoing_message.state_manager, state_manager) + state_manager.set_state("caller@c.us", "MENU") + state_manager.set_state_data("caller@c.us", {"language_code": "ru"}) + received = [] + + @self.router.incoming_call(status="offer", state="MENU", from_sender="caller@c.us") + def on_offer(notification): + received.append(notification.state_manager.get_state_data(notification.call.sender)) + + self.router.route_event(self.event()) + self.assertEqual(received, [{"language_code": "ru"}]) + + state_manager.update_state("caller@c.us", "DONE") + self.router.route_event(self.event()) + self.assertEqual(len(received), 1) + + def test_status_filter_accepts_one_or_many_values_and_unknown_status_is_routed(self): + offers, ended, other = [], [], [] + self.router.incoming_call.add_handler(offers.append, status="offer") + self.router.incoming_call.add_handler(ended.append, status=["declined", "hungUp"]) + self.router.incoming_call.add_handler(other.append) + + for status in ("offer", "declined", "hungUp", "new-status"): + self.router.route_event(self.event(status=status)) + + self.assertEqual([item.call.status for item in offers], ["offer"]) + self.assertEqual([item.call.status for item in ended], ["declined", "hungUp"]) + self.assertEqual([item.call.status for item in other], ["new-status"]) + + def test_invalid_call_filters_fail_at_registration(self): + for filters in ({"status": None}, {"status": []}, {"status": 123}, + {"status": ["offer", ""]}, {"command": "start"}): + with self.subTest(filters=filters), self.assertRaises(ValueError): + self.router.incoming_call.add_handler(lambda notification: None, **filters) + self.assertEqual(self.router.incoming_call.handlers, []) + + def test_timestamps_and_missing_fields_are_safe(self): + for value, expected in ((1_700_000_000, 1_700_000_000.0), + (1_700_000_000_000, 1_700_000_000.0), + (1_700_000_000_000_000, 1_700_000_000.0), + (946_684_800, 946_684_800.0), + (4_102_444_800, None), + (1_700_000_000_000_000_000, None), + (12_345_678_901_234_567_890, None), + (True, None), (float("nan"), None), (10 ** 1000, None)): + with self.subTest(timestamp=value): + self.assertEqual(IncomingCall.from_event(self.event(timestamp=value)).timestamp, expected) + self.assertEqual(IncomingCall.from_event({}), IncomingCall(None, None, None, None)) + + def test_existing_message_notification_remains_unchanged(self): + received = [] + self.router.message.add_handler(received.append) + event = { + "typeWebhook": "incomingMessageReceived", + "senderData": {"sender": "caller@c.us", "chatId": "caller@c.us"}, + "messageData": {"typeMessage": "textMessage", "textMessageData": {"textMessage": "Hi"}}, + } + self.router.route_event(event) + self.assertIs(type(received[0]), Notification) + self.assertEqual(received[0].message_text, "Hi") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/typing/incoming_call_contract.py b/tests/typing/incoming_call_contract.py new file mode 100644 index 0000000..a945029 --- /dev/null +++ b/tests/typing/incoming_call_contract.py @@ -0,0 +1,30 @@ +"""Static contract for incoming-call handlers; checked by mypy in CI.""" + +from typing import Callable, Optional + +from typing_extensions import assert_type + +from whatsapp_chatbot_python import GreenAPIBot, IncomingCallNotification, Notification + + +def register_handlers(bot: GreenAPIBot) -> None: + @bot.router.incoming_call(status="offer") + def on_offer(notification: IncomingCallNotification) -> None: + assert_type(notification.call.sender, Optional[str]) + + assert_type(on_offer, Callable[[IncomingCallNotification], None]) + bot.router.incoming_call.add_handler(on_offer) + + def on_general(notification: Notification) -> None: + pass + + bot.router.incoming_call.add_handler(on_general) + + @bot.router.incoming_call() # type: ignore[arg-type] + def wrong(value: int) -> None: + pass + + def wrong_direct(value: int) -> None: + pass + + bot.router.incoming_call.add_handler(wrong_direct) # type: ignore[arg-type] diff --git a/whatsapp_chatbot_python/__init__.py b/whatsapp_chatbot_python/__init__.py index cb17e73..6117d83 100644 --- a/whatsapp_chatbot_python/__init__.py +++ b/whatsapp_chatbot_python/__init__.py @@ -1,5 +1,6 @@ from .bot import Bot, GreenAPI, GreenAPIBot, GreenAPIError, GreenAPIBotError from .manager.handler import Notification +from .manager.incoming_call import IncomingCall, IncomingCallNotification from .manager.state import BaseStates __all__ = [ @@ -9,5 +10,7 @@ "GreenAPIError", "GreenAPIBotError", "Notification", + "IncomingCall", + "IncomingCallNotification", "BaseStates" ] diff --git a/whatsapp_chatbot_python/calls/__init__.py b/whatsapp_chatbot_python/calls/__init__.py index 198bfe1..109bf20 100644 --- a/whatsapp_chatbot_python/calls/__init__.py +++ b/whatsapp_chatbot_python/calls/__init__.py @@ -1,34 +1,37 @@ -"""Single-call contracts and state machine (Python 3.11+). - -Import the executor from ``.service`` after installing the ``voip`` extra. -The base chatbot package does not import this optional package. -""" - -# Check the runtime before importing modules that use StrEnum. -# ruff: noqa: E402 +"""Call contracts import without installing the optional VoIP dependencies.""" import sys if sys.version_info < (3, 11): raise ImportError("whatsapp_chatbot_python.calls requires Python 3.11 or newer") -from .contracts import CallExecutor, TransitionCallback from .models import ( - ACTIVE_STATES, - TERMINAL_STATES, - WAITING_STATES, + CallDirection, CallEndReason, CallEvent, CallExecutionResult, + CallExecutor, + CallOptions, CallSession, + CallServiceUnavailable, CallState, + IncomingCallExecutor, + InputAudioOptions, + PlaybackOptions, + ProviderError, + RecordingOptions, StateTransition, - TransitionOutcome, + TransitionCallback, + ACTIVE_STATES, + TERMINAL_STATES, + WAITING_STATES, ) + +from .service import WhatsAppCallService from .state_machine import CallStateMachine __all__ = [ - "CallExecutor", "TransitionCallback", "CallEndReason", "CallEvent", - "CallExecutionResult", "CallSession", "CallState", "StateTransition", - "TransitionOutcome", "CallStateMachine", "ACTIVE_STATES", - "TERMINAL_STATES", "WAITING_STATES", + "ACTIVE_STATES", "TERMINAL_STATES", "WAITING_STATES", "CallDirection", "CallEndReason", "CallEvent", + "CallExecutionResult", "CallExecutor", "CallOptions", "CallSession", "CallServiceUnavailable", "CallState", + "CallStateMachine", "IncomingCallExecutor", "InputAudioOptions", "PlaybackOptions", "ProviderError", + "RecordingOptions", "StateTransition", "TransitionCallback", "WhatsAppCallService", ] diff --git a/whatsapp_chatbot_python/calls/audio.py b/whatsapp_chatbot_python/calls/audio.py new file mode 100644 index 0000000..af29408 --- /dev/null +++ b/whatsapp_chatbot_python/calls/audio.py @@ -0,0 +1,279 @@ +"""PCM/RTC boundary. Audio tracks depend on narrow source/sink interfaces.""" +from __future__ import annotations + +import asyncio +from fractions import Fraction +from math import ceil, isfinite +from time import monotonic +from typing import Protocol + +from aiortc import AudioStreamTrack +from aiortc.mediastreams import MediaStreamError +from av import AudioFrame, AudioResampler +from whatsapp_api_client_python.tools.voip.audio import CallAudio + +from .models import EventSink, InputAudioOptions, MediaFailed, PcmChunk, PCM, PCM16_SAMPLE_BYTES +from .recorder import CallRecorder, Track + + +class AudioSource(Protocol): + def read_frame(self) -> bytes: ... + def replace_track(self) -> None: ... + + +class AudioSink(Protocol): + async def write_audio(self, data: bytes) -> None: ... + + +class RecordingTap: + """Align each track once, then advance by sample count, not arrival jitter.""" + def __init__(self, recorder: CallRecorder) -> None: + self._recorder = recorder + self._epoch: float | None = None + self._positions: dict[Track, int] = {} + self._media_origins: dict[Track, tuple[float, int]] = {} + + def activate(self) -> None: + if self._epoch is None: + self._epoch = monotonic() + + def write(self, track: Track, data: bytes, media_time: float | None = None) -> None: + if self._epoch is None: + return + + position = self._positions.setdefault( + track, + max(0, int((monotonic() - self._epoch) * PCM.rate)) + ) + + if media_time is not None and isfinite(media_time): + origin_time, origin_position = self._media_origins.setdefault( + track, + (media_time, position), + ) + + position = max(position, origin_position + round((media_time - origin_time) * PCM.rate)) + + try: + self._recorder.write(track, PcmChunk(position, data)) + except Exception as error: + self._recorder.disable(error) + return + + self._positions[track] = position + len(data) // PCM16_SAMPLE_BYTES + + def replace_tracks(self) -> None: + self._positions.clear() + self._media_origins.clear() + + +class BotTrack(AudioStreamTrack): + def __init__(self, source: AudioSource, tap: RecordingTap, emit: EventSink) -> None: + super().__init__() + self._source = source + self._tap = tap + self._emit = emit + self._position = 0 + self._epoch = monotonic() + + async def recv(self) -> AudioFrame: + if self.readyState != "live": + raise MediaStreamError + + try: + return await self._next_frame() + except Exception as error: + self._emit(MediaFailed(error)) + raise + + async def _next_frame(self) -> AudioFrame: + due = self._epoch + self._position / PCM.rate + + await asyncio.sleep(max(0, due - monotonic())) + + data = self._source.read_frame() + + if len(data) != PCM.bytes_per_frame: + raise ValueError("AudioSource must return exactly one PCM frame") + + self._tap.write(Track.BOT, data) + + frame = AudioFrame( + format=PCM.av_format, + layout=PCM.layout, + samples=PCM.samples_per_frame, + ) + + frame.planes[0].update(data) + + frame.sample_rate = PCM.rate + frame.pts = self._position + frame.time_base = Fraction(1, PCM.rate) + self._position += PCM.samples_per_frame + + return frame + + +class CallerSink: + def __init__( + self, sink: AudioSink, tap: RecordingTap, emit: EventSink, + options: InputAudioOptions | None = None, + ) -> None: + self._sink = sink + self._tap = tap + self._emit = emit + self._options = options or InputAudioOptions() + self._tasks: set[asyncio.Task] = set() + self._closing = False + self._failure_reported = False + + async def attach(self, track) -> None: + if self._closing: + raise RuntimeError("Caller audio sink is closing") + + task = asyncio.create_task(self._consume(track)) + self._tasks.add(task) + task.add_done_callback(self._done) + + async def _consume(self, track) -> None: + resampler = AudioResampler(format=PCM.av_format, layout=PCM.layout, rate=PCM.rate) + capacity = max(1, ceil(self._options.buffer_seconds / PCM.frame_seconds)) + queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=capacity) + finished = asyncio.Event() + sender = asyncio.create_task(self._send(queue, finished), name="call:input-send") + reader = asyncio.current_task() + sender.add_done_callback(lambda done: self._sender_done(done, reader)) + stream_ended = False + + try: + while True: + for frame in resampler.resample(await track.recv()): + if sender.done(): + return + + size = frame.samples * PCM16_SAMPLE_BYTES + data = bytes(frame.planes[0])[:size] + + media_time = ( + float(frame.pts * frame.time_base) + if frame.pts is not None and frame.time_base is not None else None + ) + + self._tap.write(Track.CALLER, data, media_time) + self._enqueue(queue, data) + except MediaStreamError: + stream_ended = True + finally: + finished.set() + + if not stream_ended: + sender.cancel() + await asyncio.gather(sender, return_exceptions=True) + + def _sender_done(self, sender: asyncio.Task, reader: asyncio.Task) -> None: + if sender.cancelled() or self._closing: + return + + error = sender.exception() + + if error is not None: + self._report_failure(error) + + if not reader.done(): + reader.cancel() + + def _report_failure(self, error: Exception) -> None: + if not self._failure_reported: + self._failure_reported = True + self._emit(MediaFailed(error)) + + @staticmethod + def _enqueue(queue: asyncio.Queue[bytes], data: bytes) -> None: + for start in range(0, len(data), PCM.bytes_per_frame): + try: + queue.put_nowait(data[start:start + PCM.bytes_per_frame]) + except asyncio.QueueFull as error: + raise BufferError("Inbound audio buffer exceeded its time limit") from error + + async def _send(self, queue: asyncio.Queue[bytes], finished: asyncio.Event) -> None: + while not finished.is_set() or not queue.empty(): + try: + data = await asyncio.wait_for(queue.get(), PCM.frame_seconds) + except TimeoutError: + continue + + await asyncio.wait_for(self._sink.write_audio(data), self._options.send_seconds) + + def _done(self, task: asyncio.Task) -> None: + self._tasks.discard(task) + + if task.cancelled(): + return + + error = task.exception() + + if error is not None: + self._report_failure(error) + + async def close(self) -> None: + self._closing = True + tasks = tuple(self._tasks) + + for task in tasks: + task.cancel() + + await asyncio.gather(*tasks, return_exceptions=True) + + +class AudioBridge: + def __init__( + self, + source: AudioSource, + sink: AudioSink, + recorder: CallRecorder, + emit: EventSink, + input_options: InputAudioOptions | None = None, + ) -> None: + self._source = source + self._sink = sink + self._tap = RecordingTap(recorder) + self._emit = emit + self._input_options = input_options + self._audio: CallAudio | None = None + self._closed = False + + def activate_recording(self) -> None: + self._tap.activate() + + async def create(self) -> CallAudio: + if self._closed: + raise RuntimeError("Audio bridge is closing") + + await self._close_current() + + if self._closed: + raise RuntimeError("Audio bridge is closing") + + self._source.replace_track() + self._tap.replace_tracks() + + local = BotTrack(self._source, self._tap, self._emit) + remote = CallerSink(self._sink, self._tap, self._emit, self._input_options) + + async def close_pair() -> None: + local.stop() + await remote.close() + + self._audio = CallAudio(local, remote.attach, close_pair) + + return self._audio + + async def close(self) -> None: + self._closed = True + await self._close_current() + + async def _close_current(self) -> None: + audio, self._audio = self._audio, None + + if audio is not None: + await audio.close() diff --git a/whatsapp_chatbot_python/calls/call_runner.py b/whatsapp_chatbot_python/calls/call_runner.py new file mode 100644 index 0000000..065d5d6 --- /dev/null +++ b/whatsapp_chatbot_python/calls/call_runner.py @@ -0,0 +1,750 @@ +"""One call: an event loop, owned operations, and a resource scope.""" +from __future__ import annotations + +import asyncio +import logging +import sys +from collections.abc import Awaitable, Callable +from contextlib import AbstractAsyncContextManager, AsyncExitStack +from time import monotonic + +from .models import ( + AcceptResult, + CallerLeft, + CallCleanupError, + CallDirection, + CallEndReason, + CallEvent, + CallExecutionResult, + CallOptions, + CallSession, + CallState, + Command, + CommandCompleted, + CommandFailed, + DeadlineExpired, + DeadlineKind, + Deadlines, + EventSink, + FinishResult, + MediaFailed, + ProviderConfig, + RemoteAnswered, + RemoteEnded, + RuntimeEvent, + StopRequested, + TransitionCallback, +) + + +logger = logging.getLogger(__name__) + + +class Events: + """Thread-safe ingress; one consumer orders observations against deadlines.""" + def __init__(self) -> None: + self._loop = asyncio.get_running_loop() + self._queue: asyncio.Queue[RuntimeEvent] = asyncio.Queue() + self._pending: RuntimeEvent | None = None + self._closed = False + + def emit(self, event: RuntimeEvent) -> bool: + if self._closed: + logger.debug("[Events.emit] Unable to add the VoIP event: The loop is closed") + return False + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop is self._loop: + self._queue.put_nowait(event) + return True + + try: + self._loop.call_soon_threadsafe(self._deliver, event) + return True + except RuntimeError as error: + logger.warning("[Events.emit] Unable to add the VoIP event: %s", type(error).__name__) + return False + + def _deliver(self, event: RuntimeEvent) -> None: + if not self._closed: + self._queue.put_nowait(event) + + async def receive(self, deadlines: Deadlines) -> RuntimeEvent: + event = self._pending or await self._next(deadlines) + + if isinstance(event, DeadlineExpired): + self._pending = None + return event + + expired = deadlines.expire_before(event.observed_at) + self._pending = event if expired is not None else None + + return expired or event + + async def _next(self, deadlines: Deadlines) -> RuntimeEvent: + if not self._queue.empty(): + return self._queue.get_nowait() + + try: + async with asyncio.timeout(deadlines.next_timeout()): + return await self._queue.get() + except TimeoutError: + expired = deadlines.expire_before(monotonic()) + + if expired is None: + raise RuntimeError("Mailbox timed out without a deadline") + + return expired + + def close(self) -> None: + self._closed = True + + +class Operations: + """Each named operation runs once; its only output is a typed event.""" + def __init__(self, emit: EventSink) -> None: + self._emit = emit + self._tasks: dict[Command, asyncio.Task] = {} + self._stopping = False + + def start( + self, + command: Command, + factory: Callable[[], Awaitable[AcceptResult | None]], + timeout: float, + ) -> None: + if self._stopping or command in self._tasks: + logger.debug("[Operations.start] Unable to create VoIP task: The task already exists") + return + + self._tasks[command] = asyncio.create_task( + self._run(command, factory, timeout), + name=f"call:{command.value}", + ) + + async def _run( + self, + command: Command, + factory: Callable[[], Awaitable[AcceptResult | None]], + timeout: float, + ) -> None: + try: + result = await asyncio.wait_for(factory(), timeout) + except asyncio.CancelledError: + raise + except Exception as error: + if not self._stopping: + self._emit(CommandFailed(command, error)) + else: + if not self._stopping: + self._emit(CommandCompleted(command, result=result)) + + async def close(self) -> None: + self.begin_stop() + tasks = tuple(self._tasks.values()) + + for task in tasks: + task.cancel() + + await asyncio.gather(*tasks, return_exceptions=True) + + def begin_stop(self) -> None: + self._stopping = True + + +async def finish_cleanup(work: Awaitable[None]) -> None: + """Drain cleanup despite repeated cancellation, then propagate cancellation.""" + task = asyncio.create_task(work, name="call:cleanup") + cancelled = False + + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError: + cancelled = True + except Exception: + break + + if cancelled: + # Retrieve a cleanup exception as well; cancellation remains the caller's outcome. + error = None if task.cancelled() else task.exception() + raise asyncio.CancelledError from error + + task.result() + + +class CallResources: + """Owns acquisition, task shutdown, remote finish and unclaimed recording.""" + def __init__( + self, + config: ProviderConfig, + options: CallOptions, + session: CallSession, + emit: EventSink, + ) -> None: + from .audio import AudioBridge + from .greenapi import GreenApiCall + from .openai import RealtimeConversation + from .recorder import CallRecorder + + self.options = options + self._session = session + self.operations = Operations(emit) + self.recorder = CallRecorder(options.recording) + + self.voice = RealtimeConversation( + config, + options.playback, + emit, + session.language, + options.shutdown_seconds, + ) + + self.audio = AudioBridge( + self.voice, + self.voice, + self.recorder, + emit, + options.input_audio, + ) + + self.api = GreenApiCall( + config, + session, + emit, + options.command_seconds, + options.shutdown_seconds, + ) + + self._stack = AsyncExitStack() + self.finish_result: FinishResult | None = None + self.cleanup_errors: list[CallCleanupError] = [] + self._pending_cleanup: set[asyncio.Task] = set() + self._closing = False + self._cleanup_still_running: asyncio.Task | None = None + self._discard_recording = False + + async def __aenter__(self) -> CallResources: + try: + await self._enter_context("voice_close", self.voice) + self._stack.push_async_callback(self._close_step, "audio_close", self.audio.close) + await self._enter_context("greenapi_close", self.api) + self._stack.push_async_callback(self._close_step, "remote_finish", self._finish_remote) + except BaseException: + await self.__aexit__(*sys.exc_info()) + raise + + return self + + async def _enter_context(self, stage: str, resource: AbstractAsyncContextManager) -> None: + await resource.__aenter__() + + self._stack.push_async_callback( + self._close_step, + stage, + resource.__aexit__, + None, + None, + None, + ) + + async def __aexit__(self, exc_type, exc, traceback) -> None: + try: + await finish_cleanup(self._close()) + except Exception: + self.discard_recording() + + if exc is None: + raise + + exc.add_note("Call resource cleanup also failed; see cleanup logs") + except BaseException: + self.discard_recording() + raise + finally: + for error in self.cleanup_errors: + logger.error( + "Call cleanup failed: session=%s stage=%s code=%s", + self._session.session_id, + error.stage, + error.code, + ) + + if exc_type is not None: + self.discard_recording() + + async def prepare(self) -> None: + await self.voice.start() + self._ensure_running() + await self.api.open(self.audio) + + def _ensure_running(self) -> None: + if self._closing: + raise RuntimeError("Call resources are closing") + + async def _finish_remote(self) -> None: + self.finish_result = await asyncio.wait_for( + self.api.finish(), + self.options.shutdown_seconds, + ) + + async def _close(self) -> None: + self._closing = True + self.operations.begin_stop() + operations = asyncio.create_task(self.operations.close(), name="call:stop-operations") + + try: + await asyncio.wait_for(asyncio.shield(operations), self.options.shutdown_seconds) + except Exception as error: + self.cleanup_errors.append(CallCleanupError("operations_close", error)) + + self._cleanup_still_running = asyncio.create_task( + self._close_after_operations(operations), name="call:finish-late-cleanup", + ) + + self._cleanup_still_running.add_done_callback(self._observe_late_cleanup) + + raise + + await self._close_owned() + self._raise_cleanup_errors() + + async def _close_after_operations(self, operations: asyncio.Task) -> None: + previous_errors = len(self.cleanup_errors) + try: + try: + await operations + except Exception as error: + self.cleanup_errors.append(CallCleanupError("operations_close", error)) + + await self._close_owned() + finally: + for error in self.cleanup_errors[previous_errors:]: + logger.error( + "Late call cleanup failed: session=%s stage=%s code=%s", + self._session.session_id, error.stage, error.code, + ) + + if self._discard_recording: + self.recorder.discard() + + def _observe_late_cleanup(self, task: asyncio.Task) -> None: + if task.cancelled(): + logger.error("Late call cleanup was cancelled: session=%s", self._session.session_id) + return + + error = task.exception() + + if error is not None: + logger.error( + "Late call cleanup failed: session=%s type=%s", + self._session.session_id, type(error).__name__, + ) + + async def _close_owned(self) -> None: + try: + await self._stack.aclose() + finally: + await self._close_step("recording_close", self._finish_recording) + + def _raise_cleanup_errors(self) -> None: + if self.cleanup_errors: + error = self.cleanup_errors[0] + raise error.cause from error + + def discard_recording(self) -> None: + if self._cleanup_still_running is not None and not self._cleanup_still_running.done(): + self._discard_recording = True + return + + self.recorder.discard() + + async def _close_step( + self, stage: str, operation: Callable[..., Awaitable[None]], *args, + ) -> None: + task = asyncio.create_task(operation(*args), name=f"call:close:{stage}") + timeout = self.options.shutdown_seconds * (2 if stage == "greenapi_close" else 1) + + try: + await asyncio.wait_for(asyncio.shield(task), timeout) + except Exception as error: + if not task.done(): + self._pending_cleanup.add(task) + task.add_done_callback(lambda done: self._observe_late_close(stage, done)) + + if stage == "greenapi_close" and self.api.close_errors: + self.cleanup_errors.extend(self.api.close_errors) + return + + if isinstance(error, CallCleanupError): + self.cleanup_errors.append(error) + return + + self.cleanup_errors.append(CallCleanupError(stage, error)) + + def _observe_late_close(self, stage: str, task: asyncio.Task) -> None: + self._pending_cleanup.discard(task) + + if task.cancelled(): + logger.error("Late call cleanup cancelled: stage=%s", stage) + return + + error = task.exception() + + if error is not None: + logger.error("Late call cleanup failed: stage=%s type=%s", stage, type(error).__name__) + + async def _finish_recording(self) -> None: + keep = self._session.end_reason in ( + CallEndReason.REMOTE_HANGUP, + CallEndReason.TALK_TIMEOUT, + ) + + if keep: + await self.recorder.finish() + return + + await self.recorder.close() + + def take_result(self, keep_recording: bool) -> CallExecutionResult: + if not keep_recording: + self.recorder.discard() + return CallExecutionResult() + + return CallExecutionResult(self.recorder.take()) + + +_DEADLINE_EVENTS = { + DeadlineKind.OFFER: CallEvent.INCOMING_EXPIRED, + DeadlineKind.RING: CallEvent.RING_TIMER_EXPIRED, + DeadlineKind.TALK: CallEvent.TALK_TIMER_EXPIRED, +} + +_START_STATES = { + CallDirection.INCOMING: CallState.RINGING, + CallDirection.OUTGOING: CallState.DIALING, +} + +_KEEP_RECORDING = frozenset({CallState.REMOTE_ENDED, CallState.TALK_TIMEOUT}) + + +class CallRunner: + """Single-use scenario. Only the transition callback mutates session state.""" + def __init__( + self, + session: CallSession, + transition: TransitionCallback, + config: ProviderConfig, + options: CallOptions, + ) -> None: + self.session = session + self._transition = transition + self._config = config + self._options = options + self._events = Events() + self._deadlines = Deadlines() + self._resources: CallResources | None = None + + def request_stop(self) -> None: + self._events.emit(StopRequested()) + + @property + def cleanup_failed(self) -> bool: + return self._resources is not None and bool(self._resources.cleanup_errors) + + def notify_incoming_ended(self, call_id: str) -> bool: + if ( + self.session.direction != CallDirection.INCOMING + or self.session.call_id != call_id + or self.session.finished + ): + logger.debug( + "[CallRunner.notify_incoming_ended] call_id=%s Unable create a VoIP incoming notification (direction=%s, call_id=%s, finished=%s)", + call_id, + self.session.direction, + self.session.call_id, + self.session.finished, + ) + + return False + + return self._events.emit(CallerLeft(call_id)) + + async def run(self) -> CallExecutionResult: + try: + self._validate_start() + + if self._offer_expired(): + self._apply(CallEvent.INCOMING_EXPIRED) + await self._reject_expired_offer() + return CallExecutionResult() + + self._resources = CallResources( + self._config, + self._options, + self.session, + self._events.emit, + ) + + async with self._resources: + self._begin_prepare() + await self._consume_events() + + self._confirm_finish() + + return self._resources.take_result(self.session.state in _KEEP_RECORDING) + except asyncio.CancelledError: + self._apply(CallEvent.SHUTDOWN_REQUESTED) + raise + except Exception as error: + code = self._error_code(error) + + self._apply( + CallEvent.INTERNAL_ERROR, + error_code=code, + ) + + logger.error( + "Call execution failed: session=%s type=%s code=%s", + self.session.session_id, + type(error).__name__, + code, + ) + + raise + finally: + self._events.close() + + if self._resources is not None: + self._resources.discard_recording() + + def _confirm_finish(self) -> None: + if self.session.state != CallState.ENDING: + return + + result = self._resources.finish_result + + if result in (FinishResult.FINISHED, FinishResult.ALREADY_ENDED): + self._apply(CallEvent.HANGUP_CONFIRMED) + return + + code = f"finish_{result.value}" if result else "finish_missing_result" + + self._apply(CallEvent.INTERNAL_ERROR, error_code=code) + + def _error_code(self, error: Exception) -> str: + if self._resources is not None and self._resources.cleanup_errors: + cleanup = self._resources.cleanup_errors[0] + + if error is cleanup.cause: + return cleanup.code + + return getattr(error, "code", type(error).__name__) + + def _validate_start(self) -> None: + if self.session.state != _START_STATES[self.session.direction]: + raise ValueError("Dequeue the session before executing a call") + + if self.session.direction == CallDirection.INCOMING: + if not self.session.call_id or self.session.incoming_deadline is None: + raise ValueError("Incoming call requires an ID and deadline") + + def _offer_expired(self) -> bool: + return ( + self.session.direction == CallDirection.INCOMING + and self.session.incoming_deadline <= monotonic() + ) + + async def _reject_expired_offer(self) -> None: + from .greenapi import GreenApiCall + + try: + api = GreenApiCall( + self._config, + self.session, + self._events.emit, + self._options.command_seconds, + ) + + async with api: + await asyncio.wait_for( + api.reject_offer(), self._options.command_seconds) + except Exception as error: + logger.warning( + "Could not reject expired offer: session=%s error=%s", + self.session.session_id, + type(error).__name__, + ) + + def _begin_prepare(self) -> None: + deadline = self.session.incoming_deadline + + if deadline is not None and deadline <= monotonic(): + self._apply(CallEvent.INCOMING_EXPIRED) + return + + self._deadlines.arm(DeadlineKind.SETUP, monotonic() + self._options.setup_seconds) + + if deadline is not None: + self._deadlines.arm(DeadlineKind.OFFER, deadline) + + self._start(Command.PREPARE, self._resources.prepare, self._options.setup_seconds) + + async def _consume_events(self) -> None: + while not self.session.finished and self.session.state != CallState.ENDING: + event = await self._events.receive(self._deadlines) + self._dispatch(event) + + def _dispatch(self, event: RuntimeEvent) -> None: + match event: + case CommandCompleted(command=command, result=result): + self._command_completed(command, event.observed_at, result) + case CommandFailed(command=command, error=error): + self._command_failed(command, error) + case RemoteAnswered(call_id=call_id): + self._answered(call_id, event.observed_at) + case RemoteEnded(reason=reason): + self._apply(CallEvent.REMOTE_IDLE, remote_reason=reason) + case CallerLeft(call_id=call_id): + self._caller_left(call_id) + case StopRequested(): + self._apply(CallEvent.SHUTDOWN_REQUESTED) + case DeadlineExpired(kind=kind): + self._deadline(kind) + case MediaFailed(error=error): + raise error + + def _command_completed( + self, command: Command, at: float, result: AcceptResult | None, + ) -> None: + match command: + case Command.PREPARE: + self._deadlines.disarm(DeadlineKind.SETUP) + self._begin_call() + case Command.DIAL: + self._apply(CallEvent.DIAL_ACCEPTED) + + if self.session.state == CallState.RINGING: + self._deadlines.arm(DeadlineKind.RING, at + self._options.ring_seconds) + + self._begin_bridge() + case Command.ACCEPT: + if result is AcceptResult.COMMAND_ACKNOWLEDGED: + self._apply(CallEvent.INCOMING_ACCEPTED) + + self._begin_bridge() + case Command.BRIDGE: + self._deadlines.disarm(DeadlineKind.BRIDGE) + self._apply(CallEvent.BRIDGE_READY) + self._activate_conversation(at) + case Command.RECONCILE | Command.GREET: + return + + def _begin_call(self) -> None: + if self.session.direction == CallDirection.INCOMING: + if self._offer_expired(): + self._apply(CallEvent.INCOMING_EXPIRED) + return + + self._start(Command.ACCEPT, self._resources.api.accept) + + return + + self._start(Command.DIAL, self._resources.api.dial) + + def _command_failed(self, command: Command, error: Exception) -> None: + if ( + command in (Command.ACCEPT, Command.RECONCILE) + and getattr(error, "code", None) == "offer_gone" + ): + event = CallEvent.INCOMING_EXPIRED if self._offer_expired() else CallEvent.CALLER_LEFT + self._apply(event) + return + + if command not in (Command.ACCEPT, Command.RECONCILE): + raise error + + if self.session.remote_accepted: + return + + if command == Command.RECONCILE: + raise error + + self._start( + Command.RECONCILE, + lambda: self._resources.api.reconcile_accept(error), + ) + + def _answered(self, call_id: str | None, at: float) -> None: + if self.session.direction == CallDirection.INCOMING and call_id != self.session.call_id: + return + + if self.session.remote_accepted: + return + + self._apply(CallEvent.REMOTE_ACCEPTED) + self._deadlines.disarm(DeadlineKind.OFFER, DeadlineKind.RING) + self._begin_bridge() + self._activate_conversation(at) + + def _begin_bridge(self) -> None: + if self.session.bridge_ready: + return + + self._deadlines.arm(DeadlineKind.BRIDGE, monotonic() + self._options.setup_seconds) + self._start(Command.BRIDGE, self._resources.api.start_audio, self._options.setup_seconds) + + def _activate_conversation(self, at: float) -> None: + if self.session.state != CallState.IN_CALL: + return + + self._deadlines.disarm(DeadlineKind.BRIDGE) + self._deadlines.arm(DeadlineKind.TALK, at + self._options.talk_seconds) + self._resources.audio.activate_recording() + + self._start( + Command.GREET, + lambda: self._resources.voice.greet_once(self.session.greeting_text), + ) + + def _caller_left(self, call_id: str) -> None: + if self.session.direction != CallDirection.INCOMING or call_id != self.session.call_id: + return + + event = CallEvent.REMOTE_IDLE if self.session.remote_accepted else CallEvent.CALLER_LEFT + + self._apply(event) + + def _deadline(self, kind: DeadlineKind) -> None: + event = _DEADLINE_EVENTS.get(kind) + + if event is None: + raise TimeoutError(f"Call {kind.value} deadline expired") + + self._apply(event) + + def _start( + self, + command: Command, + factory: Callable[[], Awaitable[AcceptResult | None]], + timeout: float | None = None, + ) -> None: + self._resources.operations.start( + command, factory, timeout or self._options.command_seconds) + + def _apply(self, event: CallEvent, **details): + transition = self._transition(self.session, event, **details) + + if transition.changed: + logger.debug( + "Call transition: %s -> %s (%s)", + transition.previous.value, + transition.current.value, + event.value, + ) + + return transition diff --git a/whatsapp_chatbot_python/calls/contracts.py b/whatsapp_chatbot_python/calls/contracts.py deleted file mode 100644 index fded04d..0000000 --- a/whatsapp_chatbot_python/calls/contracts.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Contracts shared by the executor and an application's call coordinator.""" - -from typing import Protocol - -from .models import CallEvent, CallExecutionResult, CallSession, StateTransition - - -class TransitionCallback(Protocol): - """Synchronously apply the event to the supplied session, under its owner's lock.""" - - def __call__( - self, - session: CallSession, - event: CallEvent, - *, - remote_reason: str | None = None, - error_code: str | None = None, - ) -> StateTransition: ... - - -class CallExecutor(Protocol): - """request_stop permanently stops this executor.""" - - async def execute( - self, session: CallSession, transition: TransitionCallback - ) -> CallExecutionResult: ... - - def request_stop(self) -> None: ... diff --git a/whatsapp_chatbot_python/calls/greenapi.py b/whatsapp_chatbot_python/calls/greenapi.py new file mode 100644 index 0000000..d76cd8f --- /dev/null +++ b/whatsapp_chatbot_python/calls/greenapi.py @@ -0,0 +1,294 @@ +"""Green API signaling and RTC boundary. No call-state policy lives here.""" +from __future__ import annotations + +import asyncio +from contextlib import AsyncExitStack +from typing import Awaitable, TypeVar, TYPE_CHECKING + +from .models import ( + AcceptResult, + CallCleanupError, + CallDirection, + CallSession, + EventSink, + FinishResult, + MediaFailed, + ProviderConfig, + ProviderError, + RemoteAnswered, + RemoteEnded, +) + +if TYPE_CHECKING: + from .audio import AudioBridge + +T = TypeVar("T") + + +class GreenApiCall: + """Owns HTTP session and RTC connection for exactly one call.""" + def __init__( + self, + config: ProviderConfig, + session: CallSession, + emit: EventSink, + timeout: float, + shutdown_timeout: float | None = None, + ) -> None: + self._config = config + self._session = session + self._emit = emit + self._timeout = timeout + self._shutdown_timeout = shutdown_timeout or timeout + self._stack = AsyncExitStack() + self._api = None + self._rtc = None + self._call_id = session.call_id + self._command_started = False + self._command_completed = False + self._remote_ended = False + self._closing = False + self._connection_lost = False + self.close_errors: list[CallCleanupError] = [] + self._rtc_close_task: asyncio.Task | None = None + + async def __aenter__(self) -> GreenApiCall: + from whatsapp_api_client_python.API import GreenApi + + self._api = GreenApi( + self._config.id_instance, self._config.api_token_instance, + host=self._config.api_url, raise_errors=True, + host_timeout=self._timeout, + ) + + self._stack.callback(self._close_session) + + return self + + async def __aexit__(self, *exc) -> None: + self._closing = True + await self._stack.aclose() + + if self.close_errors: + raise self.close_errors[0] + + async def open(self, audio: AudioBridge) -> None: + self._ensure_open() + self._rtc = self._api.voip.connect(audio_factory=audio.create) + + self._stack.push_async_callback(self._close_rtc) + self._rtc.on("state", self._on_state) + self._rtc.on("error", self._on_error) + self._rtc.on("disconnect", self._on_disconnect) + self._rtc.on("end_call", self._on_end_call) + await self._rtc.openAsync(timeout=self._timeout) + self._ensure_open() + + def _ensure_open(self) -> None: + if self._closing: + raise RuntimeError("Green API call is closing") + + async def _close_rtc(self) -> None: + if self._rtc_close_task is None: + self._rtc_close_task = asyncio.create_task( + self._rtc.closeAsync(), + name="call:rtc-close", + ) + + self._rtc_close_task.add_done_callback(self._observe_rtc_close) + + try: + await asyncio.wait_for( + asyncio.shield(self._rtc_close_task), self._shutdown_timeout, + ) + except Exception as error: + self.close_errors.append(CallCleanupError("rtc_close", error)) + + @staticmethod + def _observe_rtc_close(task: asyncio.Task) -> None: + if not task.cancelled(): + task.exception() + + def _close_session(self) -> None: + try: + self._api.session.close() + except Exception as error: + self.close_errors.append(CallCleanupError("http_close", error)) + + @staticmethod + async def _request(name: str, operation: Awaitable[T]) -> T: + try: + return await operation + except Exception as error: + raise ProviderError( + "greenapi", + f"{name}_{type(error).__name__}", + str(error), + ) from error + + async def dial(self) -> None: + self._ensure_open() + state = await self._request("get_state", self._api.voip.getStateAsync()) + self._ensure_open() + + if state.state != "idle": + raise RuntimeError("Cannot dial: the instance already has a call") + + self._command_started = True + + await self._request("dial", self._api.voip.dialAsync(self._session.chat_id)) + + self._command_completed = True + + async def accept(self) -> AcceptResult: + self._ensure_open() + state = await self._request("get_state", self._api.voip.getStateAsync()) + self._ensure_open() + + if state.state == "on-call" and self._owns(state): + self._command_started = True + + self._on_state(state) + + return AcceptResult.ALREADY_ACTIVE + + if state.state != "inc-call" or not self._owns(state): + raise ProviderError("greenapi", "offer_gone", "Incoming offer is no longer current") + + self._command_started = True + + await self._request("accept", self._api.voip.acceptAsync()) + + self._command_completed = True + + return AcceptResult.COMMAND_ACKNOWLEDGED + + async def reconcile_accept(self, error: Exception) -> None: + state = await self._request("get_state", self._api.voip.getStateAsync()) + + if self._owns(state): + if state.state == "on-call": + self._on_state(state) + return + + if state.state == "inc-call": + raise error + + if state.state == "idle" or state.info is not None: + raise ProviderError("greenapi", "offer_gone", "Incoming offer is no longer current") + + raise error + + async def start_audio(self) -> None: + self._ensure_open() + await self._rtc.startAudioAsync() + self._ensure_open() + + def _owns(self, state) -> bool: + if state.info is None: + return False + + if self._call_id is not None: + return state.info.id == self._call_id + + target = self._session.chat_id + + if "@" not in target: + target += "@c.us" + + return state.info.wid == target + + def _on_state(self, state) -> None: + if self._closing or not self._command_started: + return + + if state.state == "idle": + if ( + self._session.direction == CallDirection.OUTGOING + and self._call_id is None and not self._command_completed + ): + return + + self._remote_ended = True + + self._emit(RemoteEnded(state.reason)) + + return + + if not self._owns(state): + return + + self._call_id = state.info.id + + if state.state == "on-call": + self._emit(RemoteAnswered(self._call_id)) + + def _on_error(self, detail) -> None: + if self._closing or self._connection_lost: + return + + message = detail.get("message", "RTC failure") if isinstance(detail, dict) else str(detail) + code = str(detail.get("code", "rtc_error")) if isinstance(detail, dict) else "rtc_error" + + self._emit(MediaFailed(ProviderError("greenapi", code, message))) + + def _on_disconnect(self, detail=None) -> None: + if self._closing or self._remote_ended or self._connection_lost: + return + + if isinstance(detail, dict) and detail.get("permanent"): + self._connection_lost = True + self._emit(MediaFailed(ConnectionError("Green API signaling permanently disconnected"))) + + def _on_end_call(self, detail=None) -> None: + if self._closing or self._remote_ended or not self._command_started: + return + + if isinstance(detail, dict) and detail.get("reason") == "connection-lost": + self._connection_lost = True + self._emit(MediaFailed(ConnectionError("Green API call connection lost"))) + return + + reason = detail.get("cause") if isinstance(detail, dict) else None + + self._remote_ended = True + self._emit(RemoteEnded(reason)) + + async def finish(self) -> FinishResult: + """Finish only our call. A stale session must never hang up another call.""" + if self._remote_ended: + return FinishResult.ALREADY_ENDED + + if not self._command_started and self._session.direction == CallDirection.OUTGOING: + return FinishResult.ALREADY_ENDED + + state = await self._request("get_state", self._api.voip.getStateAsync()) + + if state.state == "idle": + return FinishResult.ALREADY_ENDED + + if state.info is None: + return FinishResult.UNKNOWN + + if not self._owns(state): + return FinishResult.NOT_OWNED + + match state.state: + case "inc-call": + await self._request("reject", self._api.voip.rejectAsync()) + return FinishResult.FINISHED + case "on-call" | "out-call": + await self._request("hangup", self._api.voip.hangUpAsync()) + return FinishResult.FINISHED + + return FinishResult.UNKNOWN + + async def reject_offer(self) -> bool: + """Reject only the matching unanswered offer; never hang up an active call.""" + state = await self._request("get_state", self._api.voip.getStateAsync()) + + if state.state == "inc-call" and self._owns(state): + await self._request("reject", self._api.voip.rejectAsync()) + return True + + return False diff --git a/whatsapp_chatbot_python/calls/models.py b/whatsapp_chatbot_python/calls/models.py index dde8092..b181a95 100644 --- a/whatsapp_chatbot_python/calls/models.py +++ b/whatsapp_chatbot_python/calls/models.py @@ -1,24 +1,37 @@ +"""Dependency-free contracts. Times used for deadlines are monotonic seconds.""" from __future__ import annotations + +import logging from dataclasses import dataclass, field from datetime import datetime, timezone -from enum import StrEnum +from enum import Enum +from math import isfinite from pathlib import Path +from time import monotonic +from typing import Callable, Protocol from uuid import uuid4 -def utc_now() -> datetime: - return datetime.now(timezone.utc) +PCM16_SAMPLE_BITS = 16 +PCM16_SAMPLE_BYTES = PCM16_SAMPLE_BITS // 8 +PCM16_MIN_VALUE = -(1 << (PCM16_SAMPLE_BITS - 1)) +PCM16_MAX_VALUE = (1 << (PCM16_SAMPLE_BITS - 1)) - 1 + +class CallDirection(str, Enum): + INCOMING = "incoming" + OUTGOING = "outgoing" -class CallState(StrEnum): + +class CallState(str, Enum): TRIGGERED = "triggered" QUEUED = "queued" - CANCELLED = "cancelled" DIALING = "dialing" RINGING = "ringing" CONNECTING = "connecting" IN_CALL = "in_call" ENDING = "ending" + CANCELLED = "cancelled" REJECTED = "rejected" RING_TIMEOUT = "ring_timeout" REMOTE_ENDED = "remote_ended" @@ -26,96 +39,445 @@ class CallState(StrEnum): FAILED = "failed" -class CallEvent(StrEnum): +TERMINAL_STATES = frozenset({ + CallState.CANCELLED, CallState.REJECTED, CallState.RING_TIMEOUT, + CallState.REMOTE_ENDED, CallState.TALK_TIMEOUT, CallState.FAILED, +}) + +WAITING_STATES = frozenset({CallState.TRIGGERED, CallState.QUEUED}) + +ACTIVE_STATES = frozenset(set(CallState) - TERMINAL_STATES - WAITING_STATES) + + +class CallEndReason(str, Enum): + REMOTE_HANGUP = "remote_hangup" + REMOTE_REJECTED = "remote_rejected" + RING_TIMEOUT = "ring_timeout" + TALK_TIMEOUT = "talk_timeout" + ERROR = "error" + SHUTDOWN = "shutdown" + USER_CANCELLED = "user_cancelled" + INCOMING_EXPIRED = "incoming_expired" + CALLER_LEFT = "caller_left" + + +class CallEvent(str, Enum): ENQUEUED = "enqueued" DEQUEUED = "dequeued" CANCEL_REQUESTED = "cancel_requested" DIAL_ACCEPTED = "dial_accepted" + INCOMING_ACCEPTED = "incoming_accepted" REMOTE_ACCEPTED = "remote_accepted" BRIDGE_READY = "bridge_ready" REMOTE_IDLE = "remote_idle" + CALLER_LEFT = "caller_left" + INCOMING_EXPIRED = "incoming_expired" RING_TIMER_EXPIRED = "ring_timer_expired" TALK_TIMER_EXPIRED = "talk_timer_expired" HANGUP_CONFIRMED = "hangup_confirmed" - SHUTDOWN_REQUESTED = "shutdown_requested" INTERNAL_ERROR = "internal_error" + SHUTDOWN_REQUESTED = "shutdown_requested" -class CallEndReason(StrEnum): - REMOTE_HANGUP = "remote_hangup" - REMOTE_REJECTED = "remote_rejected" - RING_TIMEOUT = "ring_timeout" - TALK_TIMEOUT = "talk_timeout" - ERROR = "error" - SHUTDOWN = "shutdown" - USER_CANCELLED = "user_cancelled" +class DeadlineKind(Enum): + SETUP = "setup" + OFFER = "offer" + RING = "ring" + TALK = "talk" + BRIDGE = "bridge" -class TransitionOutcome(StrEnum): - APPLIED = "applied" - IGNORED = "ignored" +class Command(Enum): + PREPARE = "prepare" + DIAL = "dial" + ACCEPT = "accept" + RECONCILE = "reconcile" + BRIDGE = "bridge" + GREET = "greet" -TERMINAL_STATES = frozenset( - { - CallState.REJECTED, - CallState.RING_TIMEOUT, - CallState.REMOTE_ENDED, - CallState.TALK_TIMEOUT, - CallState.FAILED, - CallState.CANCELLED, - } -) +class AcceptResult(Enum): + COMMAND_ACKNOWLEDGED = "command_acknowledged" + ALREADY_ACTIVE = "already_active" -WAITING_STATES = frozenset( - { - CallState.TRIGGERED, - CallState.QUEUED, - CallState.DIALING, - CallState.RINGING, - CallState.CONNECTING, - } -) -ACTIVE_STATES = frozenset({CallState.IN_CALL, CallState.ENDING}) +class FinishResult(Enum): + FINISHED = "finished" + ALREADY_ENDED = "already_ended" + NOT_OWNED = "not_owned" + UNKNOWN = "unknown" + +def utc_now() -> datetime: + return datetime.now(timezone.utc) -@dataclass(slots=True) + +@dataclass class CallSession: sender_id: str chat_id: str language: str session_id: str = field(default_factory=lambda: str(uuid4())) + direction: CallDirection = CallDirection.OUTGOING state: CallState = CallState.TRIGGERED call_id: str | None = None - remote_reason: str | None = None + incoming_deadline: float | None = None + greeting_text: str | None = None end_reason: CallEndReason | None = None + remote_reason: str | None = None error_code: str | None = None - triggered_at: datetime = field(default_factory=utc_now) + created_at: datetime = field(default_factory=utc_now) queued_at: datetime | None = None - dialing_at: datetime | None = None + started_at: datetime | None = None answered_at: datetime | None = None ended_at: datetime | None = None - bridge_ready: bool = False remote_accepted: bool = False + bridge_ready: bool = False + incoming_accept_completed: bool = False + + @property + def finished(self) -> bool: + return self.state in TERMINAL_STATES + + @classmethod + def incoming( + cls, + *, + sender_id: str, + call_id: str, + language: str, + incoming_deadline: float, greeting_text: str | None = None, + chat_id: str | None = None, + ) -> CallSession: + if not sender_id or not call_id: + raise ValueError("Incoming call requires sender_id and call_id") + + if not isfinite(incoming_deadline): + raise ValueError("Incoming deadline must be finite") + + return cls( + sender_id, + chat_id or sender_id, + language, + direction=CallDirection.INCOMING, + call_id=call_id, + incoming_deadline=incoming_deadline, + greeting_text=greeting_text, + ) -@dataclass(frozen=True, slots=True) +@dataclass(frozen=True) class StateTransition: previous: CallState current: CallState event: CallEvent - outcome: TransitionOutcome + applied: bool @property - def applied(self) -> bool: - return self.outcome == TransitionOutcome.APPLIED + def changed(self) -> bool: + return self.previous != self.current @property - def changed(self) -> bool: - return self.current != self.previous + def outcome(self) -> str: + return "applied" if self.applied else "ignored" -@dataclass(frozen=True, slots=True) +@dataclass class CallExecutionResult: + """Own a returned recording until take_recording or close transfers/releases it.""" recording_path: Path | None = None + + def take_recording(self) -> Path | None: + path, self.recording_path = self.recording_path, None + return path + + def close(self) -> None: + if self.recording_path is not None: + self.recording_path.unlink(missing_ok=True) + self.recording_path = None + + def __enter__(self) -> CallExecutionResult: + return self + + def __exit__(self, exc_type, exc, traceback) -> None: + try: + self.close() + except OSError as error: + logging.getLogger(__name__).warning( + "Recording removal failed: error=%s", type(error).__name__, + ) + + if exc is None: + raise + + exc.add_note("Recording removal also failed") + + +class TransitionCallback(Protocol): + def __call__( + self, + session: CallSession, + event: CallEvent, + *, + remote_reason: str | None = None, + error_code: str | None = None, + ) -> StateTransition: ... + + +class CallExecutor(Protocol): + async def execute( + self, + session: CallSession, + transition: TransitionCallback + ) -> CallExecutionResult: ... + + def request_stop(self) -> None: ... + + +class IncomingCallExecutor(CallExecutor, Protocol): + async def reject_incoming(self, call_id: str, sender_id: str) -> bool: ... + + def notify_incoming_ended(self, call_id: str) -> bool: ... + + +@dataclass(frozen=True) +class PcmFormat: + """The supported wire format: signed little-endian PCM16, mono, 24 kHz.""" + rate: int = 24 * 1000 + frame_ms: int = 20 + channels: int = field(default=1, init=False) + layout: str = field(default="mono", init=False) + av_format: str = field(default="s16", init=False) + + def __post_init__(self) -> None: + if self.rate != 24 * 1000 or self.frame_ms <= 0: + raise ValueError("Only 24 kHz PCM with a positive frame duration is supported") + + if self.rate * self.frame_ms % 1000: + raise ValueError("Frame duration must contain an integral number of samples") + + @property + def samples_per_frame(self) -> int: + return self.rate * self.frame_ms // 1000 + + @property + def bytes_per_frame(self) -> int: + return self.samples_per_frame * PCM16_SAMPLE_BYTES + + @property + def frame_seconds(self) -> float: + return self.frame_ms / 1000 + + +PCM = PcmFormat() + + +@dataclass(frozen=True) +class PcmChunk: + """A track's samples positioned on the shared call timeline.""" + position: int + data: bytes + + def __post_init__(self) -> None: + if self.position < 0 or len(self.data) % PCM16_SAMPLE_BYTES: + raise ValueError("PCM chunks require a nonnegative position and whole samples") + + +@dataclass(frozen=True) +class RecordingOptions: + enabled: bool = True + bitrate_bps: int = 96 * 1000 + max_pending_chunks: int = 256 + writer_poll_seconds: float = 0.1 + export_block_samples: int = 24 * 1000 + directory: Path | None = None + + def __post_init__(self) -> None: + positive_values( + self.bitrate_bps, + self.max_pending_chunks, + self.writer_poll_seconds, + self.export_block_samples, + ) + + +def positive_values(*values: float) -> None: + if any(not isfinite(value) or value <= 0 for value in values): + raise ValueError("Options must be finite and positive") + + +@dataclass(frozen=True) +class PlaybackOptions: + soft_limit_seconds: float = 6 + hard_limit_seconds: float = 24 + stall_seconds: float = 6 + watchdog_seconds: float = 1 + + def __post_init__(self) -> None: + positive_values( + self.soft_limit_seconds, + self.hard_limit_seconds, + self.stall_seconds, + self.watchdog_seconds, + ) + + if self.soft_limit_seconds >= self.hard_limit_seconds: + raise ValueError("Playback soft limit must be below its hard limit") + + +@dataclass(frozen=True) +class InputAudioOptions: + buffer_seconds: float = 0.5 + send_seconds: float = 2 + + def __post_init__(self) -> None: + positive_values(self.buffer_seconds, self.send_seconds) + + +@dataclass(frozen=True) +class CallOptions: + ring_seconds: float = 30 + talk_seconds: float = 120 + setup_seconds: float = 30 + command_seconds: float = 10 + shutdown_seconds: float = 10 + recording: RecordingOptions = field(default_factory=RecordingOptions) + playback: PlaybackOptions = field(default_factory=PlaybackOptions) + input_audio: InputAudioOptions = field(default_factory=InputAudioOptions) + + def __post_init__(self) -> None: + positive_values( + self.ring_seconds, + self.talk_seconds, + self.setup_seconds, + self.command_seconds, + self.shutdown_seconds, + ) + + +@dataclass(frozen=True) +class ProviderConfig: + api_url: str + id_instance: str + api_token_instance: str = field(repr=False) + openai_api_key: str = field(repr=False) + realtime_model: str + realtime_voice: str + instructions: str | None = None + + +class ProviderError(RuntimeError): + """Keep provider detail on the exception; log code without raw payloads.""" + def __init__(self, provider: str, code: str, detail: str) -> None: + self.provider = provider + self.code = code + self.detail = detail + super().__init__(f"{provider} [{code}]") + + +class CallCleanupError(RuntimeError): + """A resource could not be confirmed closed; the instance needs recovery.""" + def __init__(self, stage: str, cause: Exception) -> None: + self.stage = stage + self.cause = cause + self.code = f"cleanup_{stage}_{type(cause).__name__}" + super().__init__(f"Call cleanup failed at {stage}: {type(cause).__name__}") + + +class CallServiceUnavailable(RuntimeError): + """A previous call left resource ownership uncertain for this instance.""" + + +@dataclass(frozen=True) +class DeadlineExpired: + kind: DeadlineKind + observed_at: float + + +class Deadlines: + """One-shot deadlines; an observation exactly on the boundary is late.""" + def __init__(self) -> None: + self._due: dict[DeadlineKind, float] = {} + + def arm(self, kind: DeadlineKind, at: float) -> None: + self._due.setdefault(kind, at) + + def disarm(self, *kinds: DeadlineKind) -> None: + for kind in kinds: + self._due.pop(kind, None) + + def next_timeout(self) -> float | None: + if not self._due: + return None + + return max(0, min(self._due.values()) - monotonic()) + + def expire_before(self, observed_at: float) -> DeadlineExpired | None: + if not self._due: + return None + + kind = min(self._due, key=self._due.__getitem__) + at = self._due[kind] + + if at > observed_at: + return None + + del self._due[kind] + return DeadlineExpired(kind, at) + + +@dataclass(frozen=True) +class CommandCompleted: + command: Command + observed_at: float = field(default_factory=monotonic) + result: AcceptResult | None = None + + +@dataclass(frozen=True) +class CommandFailed: + command: Command + error: Exception + observed_at: float = field(default_factory=monotonic) + + +@dataclass(frozen=True) +class RemoteAnswered: + call_id: str | None + observed_at: float = field(default_factory=monotonic) + + +@dataclass(frozen=True) +class RemoteEnded: + reason: str | None = None + observed_at: float = field(default_factory=monotonic) + + +@dataclass(frozen=True) +class CallerLeft: + call_id: str + observed_at: float = field(default_factory=monotonic) + + +@dataclass(frozen=True) +class StopRequested: + observed_at: float = field(default_factory=monotonic) + + +@dataclass(frozen=True) +class MediaFailed: + error: Exception + observed_at: float = field(default_factory=monotonic) + + +RuntimeEvent = ( + CommandCompleted + | CommandFailed + | RemoteAnswered + | RemoteEnded + | CallerLeft + | StopRequested + | MediaFailed + | DeadlineExpired +) + +EventSink = Callable[[RuntimeEvent], None] diff --git a/whatsapp_chatbot_python/calls/openai.py b/whatsapp_chatbot_python/calls/openai.py new file mode 100644 index 0000000..7c6f474 --- /dev/null +++ b/whatsapp_chatbot_python/calls/openai.py @@ -0,0 +1,297 @@ +"""Realtime provider adapter; playback policy is owned by PlaybackController.""" +from __future__ import annotations + +import asyncio +import base64 +from time import monotonic +from uuid import uuid4 +from contextlib import AsyncExitStack + +from .models import ( + EventSink, + MediaFailed, + PlaybackOptions, + ProviderConfig, + ProviderError, + PCM, +) + +from .playback_state_machine import AppendResult, PlaybackController, PlaybackCut, PlaybackState + + +_LANGUAGE_NAMES = { + "ru": "Russian", + "en": "English", + "he": "Hebrew", + "es": "Spanish", + "kz": "Kazakh", +} + + +class RealtimeConversation: + def __init__( + self, + config: ProviderConfig, + playback: PlaybackOptions, + emit: EventSink, + language: str, + shutdown_seconds: float, + ) -> None: + self._config = config + self._options = playback + self._emit = emit + self._language = language + self._shutdown_seconds = shutdown_seconds + self._stack = AsyncExitStack() + self._connection = None + self._client = None + self._tasks: set[asyncio.Task] = set() + self._responses: set[str] = set() + self._cancelled: set[str] = set() + self._cancel_events: dict[str, float] = {} + self._playback = PlaybackController(playback) + self._greeted = False + self._closed = False + + async def __aenter__(self) -> RealtimeConversation: + self._ensure_open() + from openai import AsyncOpenAI + + self._client = AsyncOpenAI(api_key=self._config.openai_api_key) + + self._stack.push_async_callback(self._close_client) + + return self + + async def __aexit__(self, *exc) -> None: + self._closed = True + + try: + await self._stop_readers() + finally: + await self._stack.aclose() + + async def start(self) -> None: + self._ensure_open() + + manager = self._client.realtime.connect(model=self._config.realtime_model) + connection = await manager.enter() + + if self._closed: + await asyncio.wait_for(connection.close(), self._shutdown_seconds) + raise RuntimeError("Realtime conversation is closing") + + self._connection = connection + + self._stack.push_async_callback(self._close_connection) + await self._connection.session.update(session=self._session_settings()) + self._ensure_open() + await self._await_configuration() + self._ensure_open() + self._spawn(self._read()) + self._spawn(self._watch_playback()) + + def _ensure_open(self) -> None: + if self._closed: + raise RuntimeError("Realtime conversation is closing") + + async def _close_client(self) -> None: + await asyncio.wait_for(self._client.close(), self._shutdown_seconds) + + async def _close_connection(self) -> None: + await asyncio.wait_for(self._connection.close(), self._shutdown_seconds) + + def _session_settings(self) -> dict: + instructions = self._config.instructions or ( + f"Have a concise, helpful phone conversation in {self._language}." + ) + + return { + "type": "realtime", + "instructions": instructions, + "output_modalities": ["audio"], + "audio": { + "input": { + "format": {"type": "audio/pcm", "rate": PCM.rate}, + "turn_detection": {"type": "semantic_vad"}, + }, + "output": { + "format": {"type": "audio/pcm", "rate": PCM.rate}, + "voice": self._config.realtime_voice, + }, + }, + } + + async def _await_configuration(self) -> None: + async for event in self._connection: + match event.type: + case "session.updated": + return + case "error": + raise self._provider_error(event) + + raise ConnectionError("Realtime closed before session configuration") + + def _spawn(self, coroutine) -> None: + if self._closed: + coroutine.close() + return + + task = asyncio.create_task(coroutine) + self._tasks.add(task) + task.add_done_callback(self._reader_done) + + def _reader_done(self, task: asyncio.Task) -> None: + self._tasks.discard(task) + + if task.cancelled() or self._closed: + return + + error = task.exception() + + if error is not None: + self._emit(MediaFailed(error)) + + async def _stop_readers(self) -> None: + tasks = tuple(self._tasks) + + for task in tasks: + task.cancel() + + await asyncio.gather(*tasks, return_exceptions=True) + + def read_frame(self) -> bytes: + frame = self._playback.read_frame() + cuts = self._playback.take_drained_cuts() + + if cuts and self._connection is not None and not self._closed: + self._spawn(self._truncate_cuts(cuts)) + + return frame + + def replace_track(self) -> None: + cuts = self._playback.replace_track() + if self._connection is not None and not self._closed and (cuts or self._responses): + self._spawn(self._finish_interruption(cuts)) + + async def write_audio(self, data: bytes) -> None: + await self._connection.input_audio_buffer.append( + audio=base64.b64encode(data).decode("ascii")) + + async def greet_once(self, greeting_text: str | None) -> None: + if self._greeted: + return + + self._greeted = True + language_name = _LANGUAGE_NAMES.get(self._language, "English") + + greeting_instruction = ( + f'Greet the caller: "{greeting_text}" in {language_name}.' + if greeting_text else + f"Greet the caller briefly in {language_name}." + ) + + await self._connection.response.create(response={ + "instructions": greeting_instruction, + }) + + async def _read(self) -> None: + async for event in self._connection: + await self._dispatch(event) + if not self._closed: + raise ConnectionError("Realtime stream ended") + + async def _dispatch(self, event) -> None: + self._prune_cancel_events() + + match event.type: + case "response.created": + self._responses.add(event.response.id) + + if self._playback.state in (PlaybackState.DRAINING, PlaybackState.SUSPENDED): + await self._cancel_responses() + case "response.output_audio.delta": + await self._receive_audio(event) + case "response.output_audio.done": + item_id = getattr(event, "item_id", None) + + if item_id is None: + self._playback.finish_response() + else: + self._playback.complete_item(item_id, event.content_index) + case "response.done": + self._responses.discard(event.response.id) + + if event.response.status == "failed" and event.response.id not in self._cancelled: + raise ProviderError("openai", "response_failed", "Realtime response failed") + + self._cancelled.discard(event.response.id) + case "input_audio_buffer.speech_started": + await self._finish_interruption(self._playback.interrupt()) + case "error": + event_id = getattr(event.error, "event_id", None) + + if event_id in self._cancel_events: + self._cancel_events.pop(event_id) + return + + raise self._provider_error(event) + + @staticmethod + def _provider_error(event) -> ProviderError: + return ProviderError( + "openai", + event.error.code or "realtime_error", + event.error.message, + ) + + async def _receive_audio(self, event) -> None: + if event.response_id in self._cancelled or event.response_id not in self._responses: + return + + data = base64.b64decode(event.delta, validate=True) + + result = self._playback.append(event.item_id, event.content_index, data) + + if result is AppendResult.OVERFLOW: + await self._cancel_responses() + elif result is AppendResult.IGNORED and self._playback.state == PlaybackState.SUSPENDED: + await self._finish_interruption(self._playback.interrupt()) + + async def _finish_interruption(self, cuts: list[PlaybackCut]) -> None: + await self._cancel_responses() + await self._truncate_cuts(cuts) + + async def _truncate_cuts(self, cuts: list[PlaybackCut]) -> None: + + for cut in cuts: + await self._connection.conversation.item.truncate( + item_id=cut.item_id, + content_index=cut.content_index, + audio_end_ms=cut.played_ms, + ) + + async def _cancel_responses(self) -> None: + pending = self._responses - self._cancelled + self._cancelled.update(pending) + + for response_id in pending: + event_id = f"call-cancel-{uuid4().hex}" + self._cancel_events[event_id] = monotonic() + 30 + await self._connection.response.cancel(response_id=response_id, event_id=event_id) + + def _prune_cancel_events(self) -> None: + now = monotonic() + + for event_id, expires in tuple(self._cancel_events.items()): + if expires <= now: + del self._cancel_events[event_id] + + async def _watch_playback(self) -> None: + while True: + await asyncio.sleep(self._options.watchdog_seconds) + + cuts = self._playback.suspend_if_stalled() + + if cuts is not None: + await self._finish_interruption(cuts) diff --git a/whatsapp_chatbot_python/calls/playback_state_machine.py b/whatsapp_chatbot_python/calls/playback_state_machine.py index 5091420..59ed592 100644 --- a/whatsapp_chatbot_python/calls/playback_state_machine.py +++ b/whatsapp_chatbot_python/calls/playback_state_machine.py @@ -1,16 +1,20 @@ -"""Allowed transitions for one call's outgoing audio playback.""" - +"""Playback policy and bounded PCM buffer have one owner.""" +from collections import deque +from dataclasses import dataclass from enum import Enum +from time import monotonic + +from .models import PlaybackOptions, PCM, PCM16_SAMPLE_BYTES -class PlaybackState(str, Enum): +class PlaybackState(Enum): PLAYING = "playing" BACKLOG = "backlog" DRAINING = "draining" SUSPENDED = "suspended" -class PlaybackEvent(str, Enum): +class PlaybackEvent(Enum): SOFT_LIMIT = "soft_limit" RECOVERED = "recovered" HARD_LIMIT = "hard_limit" @@ -21,26 +25,233 @@ class PlaybackEvent(str, Enum): TRACK_REPLACED = "track_replaced" -_TRANSITION_RULES: dict[tuple[PlaybackState, PlaybackEvent], PlaybackState] = { +class AppendResult(Enum): + ACCEPTED = "accepted" + OVERFLOW = "overflow" + IGNORED = "ignored" + + +@dataclass(frozen=True) +class PlaybackCut: + item_id: str + content_index: int + played_ms: int + + +_TRANSITIONS = { (PlaybackState.PLAYING, PlaybackEvent.SOFT_LIMIT): PlaybackState.BACKLOG, (PlaybackState.BACKLOG, PlaybackEvent.RECOVERED): PlaybackState.PLAYING, - (PlaybackState.PLAYING, PlaybackEvent.HARD_LIMIT): PlaybackState.DRAINING, - (PlaybackState.BACKLOG, PlaybackEvent.HARD_LIMIT): PlaybackState.DRAINING, (PlaybackState.DRAINING, PlaybackEvent.DRAINED): PlaybackState.PLAYING, (PlaybackState.SUSPENDED, PlaybackEvent.RESUMED): PlaybackState.PLAYING, } -for state in (PlaybackState.PLAYING, PlaybackState.BACKLOG, PlaybackState.DRAINING): - _TRANSITION_RULES[(state, PlaybackEvent.STALLED)] = PlaybackState.SUSPENDED - _TRANSITION_RULES[(state, PlaybackEvent.INTERRUPTED)] = PlaybackState.PLAYING - -for state in PlaybackState: - _TRANSITION_RULES[(state, PlaybackEvent.TRACK_REPLACED)] = PlaybackState.PLAYING +for _state in PlaybackState: + _TRANSITIONS[_state, PlaybackEvent.HARD_LIMIT] = PlaybackState.DRAINING + _TRANSITIONS[_state, PlaybackEvent.STALLED] = PlaybackState.SUSPENDED + _TRANSITIONS[_state, PlaybackEvent.INTERRUPTED] = PlaybackState.PLAYING + _TRANSITIONS[_state, PlaybackEvent.TRACK_REPLACED] = PlaybackState.PLAYING class PlaybackStateMachine: - """Ignore duplicate or late events, as the call state machine does.""" + def __init__(self) -> None: + self._state = PlaybackState.PLAYING + + @property + def state(self) -> PlaybackState: + return self._state + + def apply(self, event: PlaybackEvent) -> PlaybackState: + self._state = _TRANSITIONS.get((self._state, event), self._state) + return self._state + + +class PlaybackController: + """Single event-loop owner. read_frame always returns one full PCM frame.""" + def __init__(self, options: PlaybackOptions) -> None: + self._options = options + self._fsm = PlaybackStateMachine() + self._chunks: deque[tuple[tuple[str, int], bytes, int]] = deque() + self._pending = bytearray() + self._pending_item: tuple[str, int] | None = None + self._received: dict[tuple[str, int], int] = {} + self._played: dict[tuple[str, int], int] = {} + self._completed: set[tuple[str, int]] = set() + self._buffered = 0 + self._last_read = monotonic() + self._drained_cuts: list[PlaybackCut] | None = None + + @property + def state(self) -> PlaybackState: + return self._fsm.state + + def append(self, item_id: str, content_index: int, data: bytes) -> AppendResult: + key = (item_id, content_index) + self._received[key] = self._received.get(key, 0) + len(data) // PCM16_SAMPLE_BYTES + + if self.state in (PlaybackState.DRAINING, PlaybackState.SUSPENDED): + return AppendResult.IGNORED + + if self._seconds(self._buffered + len(data)) > self._options.hard_limit_seconds: + self.finish_response() + self._fsm.apply(PlaybackEvent.HARD_LIMIT) + self._finish_drain_if_empty() + + return AppendResult.OVERFLOW + + if not self._buffered: + self._last_read = monotonic() + + if self._pending_item is not None and self._pending_item != key: + self.finish_response() + + self._pending_item = key + self._buffered += len(data) + self._pending.extend(data) + self._split_frames() + + if self._seconds(self._buffered) >= self._options.soft_limit_seconds: + self._fsm.apply(PlaybackEvent.SOFT_LIMIT) + + return AppendResult.ACCEPTED + + def _split_frames(self) -> None: + while len(self._pending) >= PCM.bytes_per_frame: + self._chunks.append((self._pending_item, bytes(self._pending[:PCM.bytes_per_frame]), PCM.samples_per_frame)) + del self._pending[:PCM.bytes_per_frame] + + def finish_response(self) -> None: + if not self._pending: + self._pending_item = None + return + + samples = len(self._pending) // PCM16_SAMPLE_BYTES + padding = PCM.bytes_per_frame - len(self._pending) + data = bytes(self._pending) + bytes(padding) + + self._pending.clear() + self._chunks.append((self._pending_item, data, samples)) + + self._buffered += padding + self._pending_item = None + + def complete_item(self, item_id: str, content_index: int) -> None: + key = (item_id, content_index) + + if self._pending_item == key: + self.finish_response() + + self._completed.add(key) + self._prune_completed() + + def read_frame(self) -> bytes: + self._last_read = monotonic() + self._fsm.apply(PlaybackEvent.RESUMED) + + if not self._chunks: + return bytes(PCM.bytes_per_frame) + + item_id, frame, samples = self._chunks.popleft() + self._played[item_id] = self._played.get(item_id, 0) + samples + self._buffered -= len(frame) + + self._after_read() + + return frame + + def _after_read(self) -> None: + if not self._buffered: + self._finish_drain_if_empty() + + if self.state != PlaybackState.DRAINING: + self._prune_completed() + + if self._seconds(self._buffered) < self._options.soft_limit_seconds: + self._fsm.apply(PlaybackEvent.RECOVERED) + + def _finish_drain_if_empty(self) -> None: + if self.state != PlaybackState.DRAINING or self._buffered: + return + + self._drained_cuts = self._cuts() + + self._clear() + self._fsm.apply(PlaybackEvent.DRAINED) + + def _prune_completed(self) -> None: + if self.state == PlaybackState.DRAINING: + return + + buffered_keys = {key for key, _, _ in self._chunks} + + if self._pending_item is not None: + buffered_keys.add(self._pending_item) + + for key in self._completed - buffered_keys: + self._received.pop(key, None) + self._played.pop(key, None) + self._completed.remove(key) + + def take_drained_cuts(self) -> list[PlaybackCut] | None: + cuts, self._drained_cuts = self._drained_cuts, None + return cuts + + def interrupt(self) -> list[PlaybackCut]: + cuts = self._cuts() + + self._clear() + + self._drained_cuts = None + + self._fsm.apply(PlaybackEvent.INTERRUPTED) + + return cuts + + def replace_track(self) -> list[PlaybackCut]: + cuts = self._cuts() + + self._clear() + + self._drained_cuts = None + + self._fsm.apply(PlaybackEvent.TRACK_REPLACED) + + self._last_read = monotonic() + + return cuts + + def suspend_if_stalled(self) -> list[PlaybackCut] | None: + if not self._buffered or monotonic() - self._last_read < self._options.stall_seconds: + return None + + cuts = self._cuts() + + self._clear() + + self._drained_cuts = None + + self._fsm.apply(PlaybackEvent.STALLED) + + return cuts + + def _cuts(self) -> list[PlaybackCut]: + return [ + PlaybackCut(key[0], key[1], played * 1000 // PCM.rate) + for key, received in self._received.items() + if received > (played := self._played.get(key, 0)) + ] + + def _clear(self) -> None: + self._chunks.clear() + self._pending.clear() + + self._pending_item = None + + self._received.clear() + self._played.clear() + self._completed.clear() + + self._buffered = 0 @staticmethod - def apply(state: PlaybackState, event: PlaybackEvent) -> PlaybackState: - return _TRANSITION_RULES.get((state, event), state) + def _seconds(size: int) -> float: + return size / (PCM.rate * PCM16_SAMPLE_BYTES) diff --git a/whatsapp_chatbot_python/calls/realtime_voice.py b/whatsapp_chatbot_python/calls/realtime_voice.py deleted file mode 100644 index 34e45a3..0000000 --- a/whatsapp_chatbot_python/calls/realtime_voice.py +++ /dev/null @@ -1,520 +0,0 @@ -"""One OpenAI Realtime conversation per Green-API call.""" - -from __future__ import annotations -from collections import deque -from fractions import Fraction -from time import monotonic -from typing import Callable -from uuid import uuid4 -from aiortc import AudioStreamTrack, MediaStreamTrack -from aiortc.mediastreams import MediaStreamError -from openai import AsyncOpenAI -from .playback_state_machine import PlaybackEvent, PlaybackState, PlaybackStateMachine -from .recording import CallRecorder -import asyncio -import base64 -import logging -import av - - -# Constants - -AUDIO_SAMPLE_RATE_HZ = 24 * 1000 # 24000 hz - -FRAME_DURATION_MS = 20 # 0.02 seconds - -SOFT_PLAYBACK_BUFFER_MS = 6 * 1000 # 6 seconds - -HARD_PLAYBACK_BUFFER_MS = 24 * 1000 # 24 seconds - -FRAME_SAMPLES = AUDIO_SAMPLE_RATE_HZ * FRAME_DURATION_MS // 1000 # 480 samples - -RECOVER_PLAYBACK_BUFFER_MS = SOFT_PLAYBACK_BUFFER_MS // 2 # 3 second - -FRAME_BYTES = 2 * FRAME_SAMPLES # 960 bytes (Mono PCM16) - -LANGUAGE_NAMES = { - "ru": "Russian", - "en": "English", - "he": "Hebrew", - "es": "Spanish", - "kz": "Kazakh", -} - - -# Variables - -logger = logging.getLogger(__name__) - - -class BotOutputTrack(AudioStreamTrack): - def __init__(self, voice: VoiceBotSession, generation: int): - super().__init__() - self._voice = voice - self._generation = generation - self._pts = 0 - self._next_frame_at: float | None = None - - async def recv(self) -> av.AudioFrame: - loop = asyncio.get_running_loop() - - if self._next_frame_at is None: - self._next_frame_at = loop.time() - - await asyncio.sleep(max(0, self._next_frame_at - loop.time())) - - self._next_frame_at = max(self._next_frame_at + FRAME_DURATION_MS / 1000, loop.time()) - - pcm = self._voice.take_frame(self._generation) - - if self._voice.recorder is not None: - self._voice.recorder.add_bot(pcm, self._generation) - - frame = av.AudioFrame(format="s16", layout="mono", samples=FRAME_SAMPLES) - - frame.planes[0].update(pcm) - - frame.sample_rate = AUDIO_SAMPLE_RATE_HZ - frame.time_base = Fraction(1, AUDIO_SAMPLE_RATE_HZ) - frame.pts = self._pts - self._pts += FRAME_SAMPLES - - return frame - - -class CallerAudioSink: - def __init__(self, voice: VoiceBotSession, generation: int): - self._voice = voice - self._generation = generation - self._task: asyncio.Task | None = None - - async def attach(self, track: MediaStreamTrack) -> None: - if track.kind != "audio": - raise ValueError("Expected an audio track") - - self._task = asyncio.create_task(self._consume(track)) - - async def _consume(self, track: MediaStreamTrack) -> None: - resampler = av.AudioResampler(format="s16", layout="mono", rate=AUDIO_SAMPLE_RATE_HZ) - - try: - while self._generation == self._voice.generation: - frame = await track.recv() - - for converted in resampler.resample(frame): - if self._generation != self._voice.generation: - return - - pcm = bytes(converted.planes[0])[:converted.samples * 2] - - if self._voice.recorder is not None: - self._voice.recorder.add_caller(pcm, self._generation) - - await self._voice.append_input(pcm) - except MediaStreamError: - pass - except asyncio.CancelledError: - raise - except Exception as error: - self._voice.fail(error) - - async def close(self) -> None: - if self._task is not None: - self._task.cancel() - await asyncio.gather(self._task, return_exceptions=True) - - self._task = None - - -class VoiceBotSession: - def __init__( - self, - *, - api_key: str, - model: str, - voice: str, - language: str, - on_error: Callable[[Exception], None], - recorder: CallRecorder | None = None, - ) -> None: - self._client = AsyncOpenAI(api_key=api_key) - self._model = model - self._voice = voice - self._language = language - self._on_error = on_error - self.recorder = recorder - self._manager = None - self._connection = None - self._reader: asyncio.Task | None = None - self._watchdog: asyncio.Task | None = None - self._drain_task: asyncio.Task | None = None - self._closed = False - self._greeted = False - self.generation = 0 - self._frames: deque[tuple[str | None, bytes]] = deque() - self._partial = bytearray() - self._last_item: str | None = None - self._played: dict[str, int] = {} - self._truncated: set[str] = set() - self._finished: set[str] = set() - self._item_response: dict[str, str] = {} - self._item_content_index: dict[str, int] = {} - self._active_responses: set[str] = set() - self._discarded_responses: set[str] = set() - self._cancel_events: set[str] = set() - self.playback_state = PlaybackState.PLAYING - self._playback_fsm = PlaybackStateMachine() - self._pending_truncation: set[str] = set() - self._buffered_since: float | None = None - self._last_pull_at: float | None = None - - async def start(self) -> None: - if not self._client.api_key: - raise ValueError("OpenAI API key is required for voice calls") - - self._manager = self._client.realtime.connect(model=self._model, max_retries=0) - self._connection = await self._manager.__aenter__() - - try: - await self._connection.session.update(session={ - "type": "realtime", - "model": self._model, - "output_modalities": ["audio"], - "instructions": ( - "You are a helpful voice assistant in a WhatsApp phone call. " - f"Speak in {LANGUAGE_NAMES.get(self._language, 'English')}. " - "Speak naturally and briefly. Do not use markdown." - ), - "audio": { - "input": { - "format": {"type": "audio/pcm", "rate": AUDIO_SAMPLE_RATE_HZ}, - "turn_detection": {"type": "semantic_vad"}, - }, - "output": { - "format": {"type": "audio/pcm", "rate": AUDIO_SAMPLE_RATE_HZ}, - "voice": self._voice, - }, - }, - }) - - while True: - event = await asyncio.wait_for(self._connection.recv(), timeout=15) - - if event.type == "session.updated": - break - - if event.type == "error": - raise RuntimeError("OpenAI Realtime rejected session configuration") - - self._reader = asyncio.create_task(self._read_events()) - self._watchdog = asyncio.create_task(self._watch_playback()) - except BaseException: - await self.close() - raise - - async def greet_once(self) -> None: - if self._greeted or self._closed: - return - - self._greeted = True - - await self._connection.response.create(response={ - "instructions": ( - f"Greet the caller briefly in {LANGUAGE_NAMES.get(self._language, 'English')}." - ), - }) - - async def new_output_track(self) -> BotOutputTrack: - if self.generation: - await self._interrupt(cancel=True, reason="track_replaced") - - self.generation += 1 - - if self.recorder is not None: - self.recorder.set_generation(self.generation) - - self._frames.clear() - self._partial.clear() - - self._last_item = None - self._buffered_since = None - self._last_pull_at = None - self._transition_playback(PlaybackEvent.TRACK_REPLACED) - - return BotOutputTrack(self, self.generation) - - def new_input_sink(self) -> CallerAudioSink: - return CallerAudioSink(self, self.generation) - - async def append_input(self, pcm: bytes) -> None: - if pcm and not self._closed: - await self._connection.input_audio_buffer.append( - audio=base64.b64encode(pcm).decode("ascii") - ) - - def _transition_playback(self, event: PlaybackEvent) -> None: - previous = self.playback_state - self.playback_state = self._playback_fsm.apply(previous, event) - - if self.playback_state != previous: - logger.info( - "OpenAI playback state: %s -> %s event=%s queued_ms=%d", - previous.value, self.playback_state.value, event.value, - len(self._frames) * FRAME_DURATION_MS, - ) - - def take_frame(self, generation: int) -> bytes: - if generation != self.generation: - return bytes(FRAME_BYTES) - - self._last_pull_at = monotonic() - - if self.playback_state == PlaybackState.SUSPENDED: - self._transition_playback(PlaybackEvent.RESUMED) - - if not self._frames: - return bytes(FRAME_BYTES) - - item, pcm = self._frames.popleft() - - if not self._frames and not self._partial: - self._buffered_since = None - - if self.playback_state == PlaybackState.DRAINING and self._drain_task is None: - self._drain_task = asyncio.create_task(self._finish_draining()) - - if ( - self.playback_state == PlaybackState.BACKLOG and - len(self._frames) * FRAME_DURATION_MS <= RECOVER_PLAYBACK_BUFFER_MS - ): - self._transition_playback(PlaybackEvent.RECOVERED) - - if item is not None: - self._played[item] = self._played.get(item, 0) + FRAME_SAMPLES - - return pcm - - def _queue_audio(self, item_id: str, pcm: bytes) -> bool: - if item_id in self._truncated: - return True - - if self._last_item != item_id: - self._partial.clear() - - self._last_item = item_id - - remaining_bytes = max( - 0, - (HARD_PLAYBACK_BUFFER_MS // FRAME_DURATION_MS - len(self._frames)) - * FRAME_BYTES - len(self._partial), - ) - - overflow = len(pcm) > remaining_bytes - pcm = pcm[:remaining_bytes] - - if not self._frames and not self._partial and pcm: - self._buffered_since = monotonic() - - self._partial.extend(pcm) - - while len(self._partial) >= FRAME_BYTES: - self._frames.append((item_id, bytes(self._partial[:FRAME_BYTES]))) - - del self._partial[:FRAME_BYTES] - - if len(self._frames) * FRAME_DURATION_MS >= SOFT_PLAYBACK_BUFFER_MS: - self._transition_playback(PlaybackEvent.SOFT_LIMIT) - - return not overflow - - def _finish_audio(self, item_id: str) -> bool: - if item_id in self._truncated: - return True - - self._finished.add(item_id) - - if self._partial and self._last_item == item_id: - if (len(self._frames) + 1) * FRAME_DURATION_MS > HARD_PLAYBACK_BUFFER_MS: - return False - - pcm = bytes(self._partial).ljust(FRAME_BYTES, b"\x00") - - self._frames.append((item_id, pcm)) - - self._partial.clear() - - return True - - async def _stop_generation(self, responses: set[str]) -> None: - new_responses = responses - self._discarded_responses - - self._discarded_responses.update(responses) - - for response_id in new_responses & self._active_responses: - event_id = f"playback-cancel-{uuid4().hex}" - self._cancel_events.add(event_id) - await self._connection.response.cancel(response_id=response_id, event_id=event_id) - - async def _truncate_items(self, items: set[str]) -> None: - pending = items - self._truncated - - self._truncated.update(pending) - - for item in pending: - await self._connection.conversation.item.truncate( - item_id=item, - content_index=self._item_content_index.get(item, 0), - audio_end_ms=self._played.get(item, 0) * 1000 // AUDIO_SAMPLE_RATE_HZ, - ) - - async def _finish_draining(self) -> None: - try: - items = set(self._pending_truncation) - - self._pending_truncation.clear() - await self._truncate_items(items) - self._transition_playback(PlaybackEvent.DRAINED) - except Exception as error: - self.fail(error) - finally: - self._drain_task = None - - async def _interrupt(self, *, cancel: bool = False, reason: str = "speech") -> None: - items = {item for item, _ in self._frames if item is not None} - items.update(self._pending_truncation) - - if self._last_item is not None and ( - self._partial or self._last_item not in self._finished - ): - items.add(self._last_item) - - responses = {self._item_response[item] for item in items if item in self._item_response} - - if cancel and (items or self._active_responses): - logger.warning( - "OpenAI playback interrupted: reason=%s queued_ms=%d pull_age_ms=%d", - reason, len(self._frames) * FRAME_DURATION_MS, - int((monotonic() - self._last_pull_at) * 1000) if self._last_pull_at else -1, - ) - - self._frames.clear() - self._partial.clear() - self._pending_truncation.clear() - self._last_item = None - self._buffered_since = None - - self._transition_playback( - PlaybackEvent.STALLED if reason == "stalled" else PlaybackEvent.INTERRUPTED - ) - - if cancel: - await self._stop_generation(responses | self._active_responses) - else: - self._discarded_responses.update(responses) - - await self._truncate_items(items) - - async def _hard_limit(self, response_id: str, item_id: str) -> None: - self._partial.clear() - self._pending_truncation.add(item_id) - self._transition_playback(PlaybackEvent.HARD_LIMIT) - await self._stop_generation({response_id}) - - async def _watch_playback(self) -> None: - try: - while not self._closed: - await asyncio.sleep(0.5) - - buffered_ms = len(self._frames) * FRAME_DURATION_MS - - if buffered_ms >= RECOVER_PLAYBACK_BUFFER_MS and self._buffered_since is not None: - last_progress = max(self._buffered_since, self._last_pull_at or 0) - - if (monotonic() - last_progress) * 1000 >= SOFT_PLAYBACK_BUFFER_MS: - await self._interrupt(cancel=True, reason="stalled") - except asyncio.CancelledError: - raise - except Exception as error: - self.fail(error) - - async def _read_events(self) -> None: - try: - async for event in self._connection: - if event.type == "response.created": - self._active_responses.add(event.response.id) - elif event.type == "response.output_audio.delta": - if event.response_id in self._discarded_responses: - self._item_response[event.item_id] = event.response_id - self._item_content_index[event.item_id] = event.content_index - - if event.item_id not in self._pending_truncation: - await self._truncate_items({event.item_id}) - - continue - - self._item_response[event.item_id] = event.response_id - self._item_content_index[event.item_id] = event.content_index - - if self.playback_state in {PlaybackState.DRAINING, PlaybackState.SUSPENDED}: - await self._stop_generation({event.response_id}) - await self._truncate_items({event.item_id}) - continue - - if not self._queue_audio(event.item_id, base64.b64decode(event.delta)): - await self._hard_limit(event.response_id, event.item_id) - elif event.type == "response.output_audio.done": - if self._item_response.get(event.item_id) in self._discarded_responses: - continue - - if not self._finish_audio(event.item_id): - await self._hard_limit(self._item_response[event.item_id], event.item_id) - elif event.type == "input_audio_buffer.speech_started": - await self._interrupt(cancel=True) - elif event.type == "response.done": - response_id = getattr(event.response, "id", None) - self._active_responses.discard(response_id) - - if event.response.status == "failed" and response_id not in self._discarded_responses: - raise RuntimeError("OpenAI Realtime response failed") - elif event.type == "error": - cancel_id = getattr(event.error, "event_id", None) - - if cancel_id in self._cancel_events: - self._cancel_events.discard(cancel_id) - continue - - raise RuntimeError("OpenAI Realtime reported an error") - - if not self._closed: - raise ConnectionError("OpenAI Realtime connection closed") - except asyncio.CancelledError: - raise - except Exception as error: - self.fail(error) - - def fail(self, error: Exception) -> None: - if not self._closed: - self._on_error(error) - - async def close(self) -> None: - if self._closed: - return - - self._closed = True - - if self._reader is not None: - self._reader.cancel() - await asyncio.gather(self._reader, return_exceptions=True) - - if self._watchdog is not None: - self._watchdog.cancel() - await asyncio.gather(self._watchdog, return_exceptions=True) - - if self._drain_task is not None: - self._drain_task.cancel() - await asyncio.gather(self._drain_task, return_exceptions=True) - - if self._manager is not None: - await self._manager.__aexit__(None, None, None) - - await self._client.close() diff --git a/whatsapp_chatbot_python/calls/recorder.py b/whatsapp_chatbot_python/calls/recorder.py new file mode 100644 index 0000000..59b742a --- /dev/null +++ b/whatsapp_chatbot_python/calls/recorder.py @@ -0,0 +1,320 @@ +"""Recording ownership: capture --> export --> explicit transfer to the caller.""" +from __future__ import annotations + +import asyncio +import logging +import sys +from array import array +from contextlib import ExitStack +from enum import Enum +from pathlib import Path +from queue import Full, Queue +from tempfile import NamedTemporaryFile, TemporaryFile +from threading import Thread +from typing import BinaryIO + +from .models import ( + PcmChunk, + RecordingOptions, + PCM, + PCM16_MAX_VALUE, + PCM16_MIN_VALUE, + PCM16_SAMPLE_BYTES, +) + +MP3_ENCODER = "libmp3lame" +MP3_CONTAINER = "mp3" + +logger = logging.getLogger(__name__) + + +class Track(Enum): + CALLER = "caller" + BOT = "bot" + + +class PcmCapture: + """One writer owns two temporary tracks. Producers never block on disk.""" + def __init__(self, options: RecordingOptions) -> None: + self._options = options + self._files = ExitStack() + self._tracks: dict[Track, BinaryIO] = {} + self._queue: Queue[tuple[Track, PcmChunk] | None] = Queue(options.max_pending_chunks) + self._thread: Thread | None = None + self._error: Exception | None = None + self._closed = False + self.has_samples = False + + def start(self) -> None: + if self._thread is not None: + return + + for track in Track: + self._tracks[track] = self._files.enter_context( + TemporaryFile(dir=self._options.directory)) + + self._thread = Thread(target=self._write_loop, name="call-pcm-writer", daemon=True) + + self._thread.start() + + def write(self, track: Track, chunk: PcmChunk) -> None: + if self._closed: + raise RuntimeError("PCM capture is closed") + + if self._error is not None: + raise RuntimeError("PCM capture failed") from self._error + + if not chunk.data: + return + + self.start() + + try: + self._queue.put_nowait((track, chunk)) + except Full as error: + raise BufferError("Recording writer cannot keep up") from error + + self.has_samples = True + + def _write_loop(self) -> None: + try: + while (entry := self._queue.get()) is not None: + track, chunk = entry + output = self._tracks[track] + output.seek(chunk.position * PCM16_SAMPLE_BYTES) + output.write(chunk.data) + except Exception as error: + self._error = error + + def seal(self) -> dict[Track, BinaryIO]: + """Blocking: join the writer, then return tracks borrowed until close.""" + self._closed = True + + if self._thread is None: + return {} + + self._stop_writer() + + if self._error is not None: + raise RuntimeError("PCM writer failed") from self._error + + for file in self._tracks.values(): + file.seek(0) + + return self._tracks + + def _stop_writer(self) -> None: + while self._thread.is_alive(): + try: + self._queue.put(None, timeout=self._options.writer_poll_seconds) + break + except Full: + continue + + self._thread.join() + + def close(self) -> None: + try: + if not self._closed: + self.seal() + finally: + self._files.close() + + +class Mp3Encoder: + """Synchronous exporter. Input tracks stay owned by PcmCapture.""" + def __init__(self, options: RecordingOptions) -> None: + self._options = options + + def encode(self, tracks: dict[Track, BinaryIO], path: Path) -> None: + import av + + with av.open(str(path), mode="w", format=MP3_CONTAINER) as container: + stream = container.add_stream(MP3_ENCODER, rate=PCM.rate) + stream.layout = PCM.layout + stream.bit_rate = self._options.bitrate_bps + position = 0 + + while data := self._mix_block(tracks): + frame = self._frame(data, position) + + for packet in stream.encode(frame): + container.mux(packet) + + position += frame.samples + for packet in stream.encode(None): + container.mux(packet) + + def _mix_block(self, tracks: dict[Track, BinaryIO]) -> bytes: + size = self._options.export_block_samples * PCM16_SAMPLE_BYTES + blocks = [file.read(size) for file in tracks.values()] + length = max(map(len, blocks), default=0) + + if not length: + return b"" + + samples = [self._samples(block.ljust(length, b"\0")) for block in blocks] + + mixed = array("h", ( + max(PCM16_MIN_VALUE, min(PCM16_MAX_VALUE, sum(values))) + for values in zip(*samples) + )) + + if sys.byteorder != "little": + mixed.byteswap() + + return mixed.tobytes() + + @staticmethod + def _samples(data: bytes) -> array: + samples = array("h") + samples.frombytes(data) + + if sys.byteorder != "little": + samples.byteswap() + + return samples + + @staticmethod + def _frame(data: bytes, position: int): + import av + from fractions import Fraction + + frame = av.AudioFrame( + format=PCM.av_format, + layout=PCM.layout, + samples=len(data) // PCM16_SAMPLE_BYTES, + ) + + frame.planes[0].update(data) + frame.sample_rate = PCM.rate + frame.pts = position + frame.time_base = Fraction(1, PCM.rate) + + return frame + + +class CallRecorder: + """Async facade. close deletes unclaimed output; take transfers ownership.""" + def __init__(self, options: RecordingOptions) -> None: + self._options = options + self._capture = PcmCapture(options) + self._encoder = Mp3Encoder(options) + self._export: asyncio.Task | None = None + self._cleanup: asyncio.Task | None = None + self._path: Path | None = None + self._closed = False + self._failed = False + self._discard_requested = False + + def write(self, track: Track, chunk: PcmChunk) -> None: + if self._closed or self._export is not None or self._failed: + return + + if self._options.enabled: + try: + self._capture.write(track, chunk) + except Exception as error: + self.disable(error) + + def disable(self, error: Exception) -> None: + if not self._failed: + self._failed = True + logger.warning("VoIP recording disabled: error=%s", type(error).__name__) + + async def finish(self) -> Path | None: + if self._export is None: + self._export = asyncio.create_task(asyncio.to_thread(self._finish_blocking)) + self._export.add_done_callback(self._after_export) + + return await asyncio.shield(self._export) + + def _finish_blocking(self) -> Path | None: + try: + tracks = self._capture.seal() + + if self._failed or not self._capture.has_samples: + return None + + with NamedTemporaryFile( + suffix=".mp3", + dir=self._options.directory, + delete=False, + ) as output: + self._path = Path(output.name) + + self._encoder.encode(tracks, self._path) + + return self._path + except Exception as error: + self.discard() + logger.warning("VoIP recording export failed: error=%s", type(error).__name__) + return None + finally: + try: + self._capture.close() + except Exception as error: + logger.warning("VoIP recording cleanup failed: error=%s", type(error).__name__) + + def take(self) -> Path | None: + if self._export is None or not self._export.done(): + raise RuntimeError("Finish recording before taking ownership") + + self._export.result() + + path, self._path = self._path, None + + return path + + def discard(self) -> None: + if self._export is not None and not self._export.done(): + self._discard_requested = True + return + + self._discard_now() + + def _after_export(self, task: asyncio.Task) -> None: + if not task.cancelled(): + task.exception() + + if self._discard_requested: + self._discard_now() + + def _discard_now(self) -> None: + if self._path is None: + return + + try: + self._path.unlink(missing_ok=True) + except OSError as error: + logger.warning("VoIP recording removal failed: error=%s", type(error).__name__) + return + + self._path = None + self._discard_requested = False + + async def close(self) -> None: + """Own cleanup even if this particular waiter is cancelled.""" + if self._cleanup is None: + self._closed = True + self._cleanup = asyncio.create_task(self._close_owned()) + self._cleanup.add_done_callback(self._observe_cleanup) + + await asyncio.shield(self._cleanup) + + async def _close_owned(self) -> None: + try: + if self._export is not None: + await self._export + else: + await asyncio.to_thread(self._capture.close) + except Exception as error: + logger.warning("VoIP recording cleanup failed: error=%s", type(error).__name__) + finally: + self.discard() + + @staticmethod + def _observe_cleanup(task: asyncio.Task) -> None: + # Retrieve errors for cancelled waiters; awaiting close still raises them. + if not task.cancelled(): + task.exception() diff --git a/whatsapp_chatbot_python/calls/recording.py b/whatsapp_chatbot_python/calls/recording.py deleted file mode 100644 index f44fa12..0000000 --- a/whatsapp_chatbot_python/calls/recording.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Best-effort recording of both sides of one call.""" - -from __future__ import annotations -from array import array -from pathlib import Path -from queue import Full, Queue -from tempfile import TemporaryFile, NamedTemporaryFile -from threading import Event, Thread -from time import monotonic -import logging -import av - - -# Constants - -RATE = 24 * 1000 - -MAX_PENDING_CHUNKS = 256 - -MIX_SAMPLES = 24 * 1000 - - -class CallRecorder: - def __init__(self, logger: logging.Logger) -> None: - self._logger = logger - self._queue: Queue[tuple[str, int, bytes] | None] = Queue(MAX_PENDING_CHUNKS) - self._started_at: float | None = None - self._generation = 0 - self._failed = Event() - self._thread: Thread | None = None - self._caller = TemporaryFile() - self._bot = TemporaryFile() - self._lengths = {"caller": 0, "bot": 0} - self._has_audio = False - - def start(self) -> None: - if self._started_at is None: - self._started_at = monotonic() - self._thread = Thread(target=self._write, name="voip-recording", daemon=True) - - self._thread.start() - - def set_generation(self, generation: int) -> None: - self._generation = generation - - def add_caller(self, pcm: bytes, generation: int) -> None: - self._add("caller", pcm, generation) - - def add_bot(self, pcm: bytes, generation: int) -> None: - self._add("bot", pcm, generation) - - def _add(self, side: str, pcm: bytes, generation: int) -> None: - if self._started_at is None or self._failed.is_set() or generation != self._generation or not pcm: - return - - if len(pcm) % 2: - self._failed.set() - return - - # Incoming frames arrive after capture; place their start at the beginning - # of the received interval. The outgoing frame starts when recv() yields it. - elapsed = monotonic() - self._started_at - - if side == "caller": - elapsed -= len(pcm) / (2 * RATE) - - offset = max(0, round(elapsed * RATE)) - - try: - self._queue.put_nowait((side, offset, pcm)) - except Full: - self._failed.set() - self._logger.warning("VoIP recording queue overflow") - - def _write(self) -> None: - streams = {"caller": self._caller, "bot": self._bot} - - try: - while (item := self._queue.get()) is not None: - side, offset, pcm = item - stream = streams[side] - stream.seek(offset * 2) - stream.write(pcm) - self._lengths[side] = max(self._lengths[side], offset + len(pcm) // 2) - self._has_audio = True - self._queue.task_done() - - self._queue.task_done() - except Exception: - self._failed.set() - self._logger.exception("VoIP recording write failed") - - def finish(self) -> Path | None: - """Finish queued writes and return an MP3 owned by the caller.""" - - if self._thread is not None: - while self._thread.is_alive(): - try: - self._queue.put(None, timeout=0.1) - break - except Full: - pass - - self._thread.join() - try: - if self._failed.is_set() or not self._has_audio: - return None - - with NamedTemporaryFile(prefix="voip-call-", suffix=".mp3", delete=False) as output: - path = Path(output.name) - - try: - self._encode(path) - return path - except Exception: - path.unlink(missing_ok=True) - self._logger.exception("VoIP recording encoding failed") - - return None - finally: - self._caller.close() - self._bot.close() - - def _encode(self, path: Path) -> None: - length = max(self._lengths.values()) - - self._caller.seek(0) - self._bot.seek(0) - - with av.open(str(path), "w") as container: - stream = container.add_stream("libmp3lame", rate=RATE) - stream.layout = "mono" - - for _ in range(0, length, MIX_SAMPLES): - count = min(MIX_SAMPLES, length - _) - caller = array("h") - bot = array("h") - - caller.frombytes(self._caller.read(count * 2).ljust(count * 2, b"\0")) - bot.frombytes(self._bot.read(count * 2).ljust(count * 2, b"\0")) - - mixed = array("h", (max(-32768, min(32767, a + b)) for a, b in zip(caller, bot))) - frame = av.AudioFrame(format="s16", layout="mono", samples=count) - - frame.planes[0].update(mixed.tobytes()) - frame.sample_rate = RATE - - for packet in stream.encode(frame): - container.mux(packet) - for packet in stream.encode(None): - container.mux(packet) diff --git a/whatsapp_chatbot_python/calls/runtime.py b/whatsapp_chatbot_python/calls/runtime.py deleted file mode 100644 index 508daeb..0000000 --- a/whatsapp_chatbot_python/calls/runtime.py +++ /dev/null @@ -1,136 +0,0 @@ -from __future__ import annotations -from dataclasses import dataclass, field -from enum import StrEnum -from time import monotonic -from .models import CallState -import asyncio - - -class RuntimeEventType(StrEnum): - STATE = "state" - BRIDGE_READY = "bridge_ready" - BRIDGE_FAILED = "bridge_failed" - VOICE_FAILED = "voice_failed" - END_CALL = "end_call" - DISCONNECT = "disconnect" - SHUTDOWN = "shutdown" - - -class RuntimeTimer(StrEnum): - RING = "ring" - TALK = "talk" - BRIDGE = "bridge" - - -@dataclass(frozen=True, slots=True) -class RuntimeEvent: - type: RuntimeEventType - state: str | None = None - reason: str | None = None - error: Exception | None = None - permanent: bool = False - - @classmethod - def server_state(cls, state: str, reason: str | None = None) -> RuntimeEvent: - return cls(RuntimeEventType.STATE, state=state, reason=reason) - - @classmethod - def bridge_ready(cls) -> RuntimeEvent: - return cls(RuntimeEventType.BRIDGE_READY) - - @classmethod - def bridge_failed(cls, error: Exception) -> RuntimeEvent: - return cls(RuntimeEventType.BRIDGE_FAILED, error=error) - - @classmethod - def voice_failed(cls, error: Exception) -> RuntimeEvent: - return cls(RuntimeEventType.VOICE_FAILED, error=error) - - @classmethod - def end_call(cls, reason: str | None = None) -> RuntimeEvent: - return cls(RuntimeEventType.END_CALL, reason=reason) - - @classmethod - def disconnect(cls, permanent: bool) -> RuntimeEvent: - return cls(RuntimeEventType.DISCONNECT, permanent=permanent) - - @classmethod - def shutdown(cls) -> RuntimeEvent: - return cls(RuntimeEventType.SHUTDOWN) - - -@dataclass(slots=True) -class CallRuntime: - ring_timeout_seconds: float - talk_timeout_seconds: float - bridge_timeout_seconds: float - events: asyncio.Queue[RuntimeEvent] = field(default_factory=asyncio.Queue) - ring_deadline: float | None = None - talk_deadline: float | None = None - bridge_deadline: float | None = None - - def start_ringing(self) -> None: - self.ring_deadline = monotonic() + self.ring_timeout_seconds - - def remote_accepted(self, bridge_ready: bool) -> None: - if self.talk_deadline is None: - self.talk_deadline = monotonic() + self.talk_timeout_seconds - - if not bridge_ready and self.bridge_deadline is None: - self.bridge_deadline = monotonic() + self.bridge_timeout_seconds - - def bridge_negotiated(self) -> None: - self.bridge_deadline = None - - async def next_event(self, state: CallState) -> RuntimeEvent | None: - try: - return self.events.get_nowait() - except asyncio.QueueEmpty: - pass - - timeout = self._next_timeout(state) - - try: - # Python 3.11 wait_for() can swallow cancellation when an event becomes ready. Wait in the current task - # instead: https://github.com/python/cpython/issues/86296 - async with asyncio.timeout(timeout): - return await self.events.get() - except TimeoutError: - return None - - def expired_timer(self, state: CallState) -> RuntimeTimer | None: - now = monotonic() - - if state == CallState.RINGING and self._expired(self.ring_deadline, now): - return RuntimeTimer.RING - - if state in {CallState.CONNECTING, CallState.IN_CALL}: - if self._expired(self.talk_deadline, now): - return RuntimeTimer.TALK - - if state == CallState.CONNECTING and self._expired(self.bridge_deadline, now): - return RuntimeTimer.BRIDGE - - return None - - def _next_timeout(self, state: CallState) -> float | None: - deadlines: list[float] = [] - - if state == CallState.RINGING and self.ring_deadline is not None: - deadlines.append(self.ring_deadline) - - if state in {CallState.CONNECTING, CallState.IN_CALL}: - if self.talk_deadline is not None: - deadlines.append(self.talk_deadline) - - if state == CallState.CONNECTING and self.bridge_deadline is not None: - deadlines.append(self.bridge_deadline) - - if not deadlines: - return None - - return max(0.0, min(deadlines) - monotonic()) - - @staticmethod - def _expired(deadline: float | None, now: float) -> bool: - return deadline is not None and now >= deadline diff --git a/whatsapp_chatbot_python/calls/service.py b/whatsapp_chatbot_python/calls/service.py index 6a30c60..6f3cdee 100644 --- a/whatsapp_chatbot_python/calls/service.py +++ b/whatsapp_chatbot_python/calls/service.py @@ -1,439 +1,180 @@ +"""Application facade: serialized admission, execution and incoming control.""" from __future__ import annotations -from contextlib import suppress -from dataclasses import dataclass -from functools import partial -from pathlib import Path -from whatsapp_api_client_python.API import GreenAPI -from whatsapp_api_client_python.tools.voip import CallAudio, CallsConnection -from .contracts import TransitionCallback -from .realtime_voice import VoiceBotSession -from .recording import CallRecorder -from .models import CallEvent, CallExecutionResult, CallSession, CallState, TERMINAL_STATES -from .runtime import CallRuntime, RuntimeEvent, RuntimeEventType, RuntimeTimer -import asyncio -import logging - -# Constants - -BRIDGE_NEGOTIATION_TIMEOUT_SECONDS = 15 - -INITIAL_STATE_TIMEOUT_SECONDS = 10 - - -@dataclass(slots=True) -class _CallExecution: - """Resources and progress of one execution, including partially completed setup.""" - session: CallSession - transition: TransitionCallback - recorder: CallRecorder - runtime: CallRuntime | None = None - voice: VoiceBotSession | None = None - api: GreenAPI | None = None - calls: CallsConnection | None = None - initial_state: asyncio.Future[str] | None = None - bridge_task: asyncio.Task[None] | None = None - dial_started: bool = False - dialed: bool = False - execution_completed: bool = False - cancellation: asyncio.CancelledError | None = None - recording_path: Path | None = None +import asyncio +from contextlib import suppress +from dataclasses import replace + +from .models import ( + CallEvent, + CallDirection, + CallExecutionResult, + CallOptions, + CallSession, + CallServiceUnavailable, + PlaybackOptions, + ProviderConfig, + RecordingOptions, + TransitionCallback, +) class WhatsAppCallService: + """One service per instance. request_stop permanently closes admission.""" def __init__( self, *, api_url: str, - id_instance: str, - api_token_instance: str, + id_instance: str, api_token_instance: str, openai_api_key: str, realtime_model: str, realtime_voice: str, - ring_timeout_seconds: int, - talk_timeout_seconds: int, - shutdown_timeout_seconds: int, - logger: logging.Logger, + ring_timeout_seconds: float = 30, + talk_timeout_seconds: float = 120, + shutdown_timeout_seconds: float = 10, + options: CallOptions | None = None, + recording: RecordingOptions | None = None, + playback: PlaybackOptions | None = None, + instructions: str | None = None, ) -> None: - self._api_url = api_url - self._id_instance = id_instance - self._api_token_instance = api_token_instance - self._openai_api_key = openai_api_key - self._realtime_model = realtime_model - self._realtime_voice = realtime_voice - self._ring_timeout = ring_timeout_seconds - self._talk_timeout = talk_timeout_seconds - self._shutdown_timeout = shutdown_timeout_seconds - self._logger = logger - self._active_loop: asyncio.AbstractEventLoop | None = None - self._active_runtime: CallRuntime | None = None - self._stop_requested = False + if not all((id_instance, api_token_instance, openai_api_key)): + raise ValueError("Instance ID and provider credentials are required") + + self._config = ProviderConfig( + api_url, + id_instance, + api_token_instance, + openai_api_key, + realtime_model, + realtime_voice, + instructions, + ) - def request_stop(self) -> None: - self._stop_requested = True - loop = self._active_loop - runtime = self._active_runtime + base = options or CallOptions( + ring_seconds=ring_timeout_seconds, + talk_seconds=talk_timeout_seconds, + shutdown_seconds=shutdown_timeout_seconds, + ) + + self._options = replace( + base, + recording=recording or base.recording, + playback=playback or base.playback, + ) - if loop is not None and runtime is not None and loop.is_running(): - loop.call_soon_threadsafe(runtime.events.put_nowait, RuntimeEvent.shutdown()) + self._gate = asyncio.Lock() + self._runner = None + self._runner_with_incomplete_cleanup = None + self._stopped = False + self._healthy = True + + @property + def healthy(self) -> bool: + return self._healthy async def execute( self, session: CallSession, transition: TransitionCallback, ) -> CallExecutionResult: - if self._stop_requested: - transition(session, CallEvent.SHUTDOWN_REQUESTED, error_code="shutdown") - return CallExecutionResult() + async with self._gate: + already_finished = session.finished - execution = _CallExecution(session, transition, CallRecorder(self._logger)) + if not self._admit(session, transition): + if ( + self._stopped and session.direction == CallDirection.INCOMING + and session.call_id and not already_finished + ): + with suppress(Exception): + await self.reject_incoming(session.call_id, session.sender_id) - try: - await self._execute_call(execution) - except asyncio.CancelledError as error: - self._record_cancellation(execution, error) - raise - finally: - await self._finalize_call(execution) + return CallExecutionResult() - return CallExecutionResult(execution.recording_path) + return await self._execute_admitted(session, transition) - async def _execute_call(self, execution: _CallExecution) -> None: - # Setup failures propagate to the caller; call failures become FSM events. - await self._prepare_call(execution) + async def _execute_admitted( + self, + session: CallSession, + transition: TransitionCallback, + ) -> CallExecutionResult: + from .call_runner import CallRunner try: - await self._run_call(execution) - except Exception as error: - await self._handle_call_failure(execution, error) - - execution.execution_completed = True - - async def _prepare_call(self, execution: _CallExecution) -> None: - runtime = CallRuntime( - ring_timeout_seconds=self._ring_timeout, - talk_timeout_seconds=self._talk_timeout, - bridge_timeout_seconds=BRIDGE_NEGOTIATION_TIMEOUT_SECONDS, - ) - - execution.runtime = runtime - self._active_loop = asyncio.get_running_loop() - self._active_runtime = runtime - - if self._stop_requested: - runtime.events.put_nowait(RuntimeEvent.shutdown()) - - execution.voice = VoiceBotSession( - api_key=self._openai_api_key, - model=self._realtime_model, - voice=self._realtime_voice, - language=execution.session.language, - on_error=lambda error: runtime.events.put_nowait(RuntimeEvent.voice_failed(error)), - recorder=execution.recorder, - ) - - await execution.voice.start() - - execution.api = GreenAPI(self._id_instance, self._api_token_instance, host=self._api_url) - - execution.calls = execution.api.voip.connect( - audio_factory=partial(self._create_audio_session, execution.voice), - ) - - execution.initial_state = asyncio.get_running_loop().create_future() - - self._register_listeners(execution) - - async def _create_audio_session(self, voice: VoiceBotSession) -> CallAudio: - track = await voice.new_output_track() + self._runner = CallRunner( + session, + transition, + self._config, + self._options, + ) - try: - sink = voice.new_input_sink() - except BaseException: - track.stop() + return await self._runner.run() + except asyncio.CancelledError: + transition(session, CallEvent.SHUTDOWN_REQUESTED) raise + except Exception as error: + transition( + session, + CallEvent.INTERNAL_ERROR, + error_code=getattr(error, "code", type(error).__name__), + ) - return CallAudio(track, sink.attach, partial(self._close_audio_session, sink, track)) - - @staticmethod - async def _close_audio_session(sink, track) -> None: - try: - await sink.close() + raise finally: - track.stop() - - def _register_listeners(self, execution: _CallExecution) -> None: - calls = execution.calls - runtime = execution.runtime - - calls.on("state", partial(self._on_state, execution)) - - calls.on("end_call", lambda detail: runtime.events.put_nowait( - RuntimeEvent.end_call(detail.get("cause", None)), - )) - - calls.on("disconnect", partial(self._on_disconnect, execution)) - - calls.on("error", lambda detail: runtime.events.put_nowait( - RuntimeEvent.bridge_failed(RuntimeError("callsRtc reported an error")), - )) - - @staticmethod - def _on_state(execution: _CallExecution, state) -> None: - if not execution.initial_state.done(): - execution.initial_state.set_result(state.state) - - if execution.dial_started: - execution.runtime.events.put_nowait(RuntimeEvent.server_state(state.state, state.reason)) - - @staticmethod - def _on_disconnect(execution: _CallExecution, detail) -> None: - permanent = bool(detail.get("permanent")) + if self._runner is not None and self._runner.cleanup_failed: + self._healthy = False + self._runner_with_incomplete_cleanup = self._runner - if permanent and not execution.initial_state.done(): - execution.initial_state.set_exception(RuntimeError("callsRtc connection refused")) + self._runner = None - execution.runtime.events.put_nowait(RuntimeEvent.disconnect(permanent)) + def _admit(self, session: CallSession, transition: TransitionCallback) -> bool: + if session.finished: + return False - @staticmethod - def _on_bridge_done(execution: _CallExecution, task: asyncio.Task[None]) -> None: - if task.cancelled(): - return + if not self._healthy: + transition(session, CallEvent.INTERNAL_ERROR, error_code="service_unhealthy") + raise CallServiceUnavailable("Call service requires recovery after cleanup failure") - error = task.exception() - event = RuntimeEvent.bridge_failed(error) if error else RuntimeEvent.bridge_ready() + if self._stopped: + transition(session, CallEvent.SHUTDOWN_REQUESTED) + return False - execution.runtime.events.put_nowait(event) + return True - async def _run_call(self, execution: _CallExecution) -> None: - await execution.calls.openAsync(timeout=INITIAL_STATE_TIMEOUT_SECONDS) - - state = await asyncio.wait_for(execution.initial_state, timeout=INITIAL_STATE_TIMEOUT_SECONDS) - - if state != "idle": - raise RuntimeError(f"Instance already has a call: {state}") - - if self._stop_requested: - execution.transition(execution.session, CallEvent.SHUTDOWN_REQUESTED, error_code="shutdown") - else: - execution.dial_started = True - - await execution.api.voip.dialAsync(execution.session.chat_id) - - execution.dialed = True - - execution.transition(execution.session, CallEvent.DIAL_ACCEPTED) - execution.runtime.start_ringing() - - # Bridge completion confirms SDP, not ICE/DTLS or live audio. - execution.bridge_task = asyncio.create_task(execution.calls.startAudioAsync()) + def request_stop(self) -> None: + self._stopped = True - execution.bridge_task.add_done_callback(partial(self._on_bridge_done, execution)) + if self._runner is not None: + self._runner.request_stop() - await self._process_events(execution) + def notify_incoming_ended(self, call_id: str) -> bool: + if self._runner is None: + return False - async def _process_events(self, execution: _CallExecution) -> None: - while execution.session.state not in TERMINAL_STATES: - event = await execution.runtime.next_event(execution.session.state) + return self._runner.notify_incoming_ended(call_id) - if event is None: - await self._handle_timeout( - execution.runtime, execution.api, execution.session, execution.transition, - ) - else: - await self._handle_event( - event, execution.runtime, execution.api, execution.voice, - execution.recorder, execution.session, execution.transition, - ) + async def reject_incoming(self, call_id: str, sender_id: str) -> bool: + from .call_runner import finish_cleanup + from .greenapi import GreenApiCall + from .models import CallDirection - async def _handle_call_failure(self, execution: _CallExecution, error: Exception) -> None: - self._logger.error( - "VoIP execution failed: session=%s error=%s", - execution.session.session_id, - type(error).__name__, + session = CallSession( + sender_id, + sender_id, + "", + call_id=call_id, + direction=CallDirection.INCOMING, ) - execution.transition( - execution.session, CallEvent.INTERNAL_ERROR, - error_code=type(error).__name__, + api = GreenApiCall( + self._config, + session, + lambda event: None, + self._options.command_seconds, ) - if execution.dialed: - await self._safe_hang_up(execution.api) - - @staticmethod - def _record_cancellation(execution: _CallExecution, error: asyncio.CancelledError) -> None: - if execution.cancellation is None: - execution.cancellation = error - - # A failing callback must not prevent resource cleanup. - with suppress(Exception): - execution.transition( - execution.session, CallEvent.SHUTDOWN_REQUESTED, error_code="cancelled", - ) - - async def _wait_for_cleanup(self, coroutine, execution: _CallExecution) -> None: - # Own the cleanup task until completion, including repeated cancel(). - task = asyncio.create_task(coroutine) - - while True: - try: - await asyncio.shield(task) - return - except asyncio.CancelledError as error: - self._record_cancellation(execution, error) - - if task.cancelled(): - raise + await api.__aenter__() - async def _finalize_call(self, execution: _CallExecution) -> None: try: - await self._close_call(execution) + return await asyncio.wait_for(api.reject_offer(), self._options.command_seconds) finally: - self._finish_recording(execution) - - if execution.cancellation is not None: - raise execution.cancellation - - async def _close_call(self, execution: _CallExecution) -> None: - try: - await self._wait_for_cleanup(self._close_connections(execution), execution) - finally: - await self._hang_up_after_cancellation(execution) - - async def _close_connections(self, execution: _CallExecution) -> None: - # Preserve shutdown order: RTC, voice session, then the bridge task. - if execution.calls is not None: - with suppress(Exception): - await asyncio.wait_for(execution.calls.closeAsync(), timeout=self._shutdown_timeout) - - if execution.voice is not None: - with suppress(Exception): - await asyncio.wait_for(execution.voice.close(), timeout=self._shutdown_timeout) - - if execution.bridge_task is not None: - if not execution.bridge_task.done(): - execution.bridge_task.cancel() - - with suppress(Exception): - await asyncio.wait_for( - asyncio.gather(execution.bridge_task, return_exceptions=True), - timeout=self._shutdown_timeout, - ) - - async def _hang_up_after_cancellation(self, execution: _CallExecution) -> None: - # The dial request may have reached the server before its response arrived. - if execution.cancellation is not None and execution.dial_started: - with suppress(Exception): - await self._wait_for_cleanup( - asyncio.wait_for(self._safe_hang_up(execution.api), timeout=self._shutdown_timeout), - execution, - ) - - def _finish_recording(self, execution: _CallExecution) -> None: - self._active_runtime = None - self._active_loop = None - path = execution.recorder.finish() - - keep_recording = ( - execution.execution_completed - and execution.cancellation is None - and execution.session.state in {CallState.REMOTE_ENDED, CallState.TALK_TIMEOUT} - ) - - if path is not None and not keep_recording: - path.unlink(missing_ok=True) - path = None - - execution.recording_path = path - - async def _handle_event( - self, - event: RuntimeEvent, - runtime: CallRuntime, - api: GreenAPI, - voice: VoiceBotSession, - recorder: CallRecorder, - call_session: CallSession, - transition: TransitionCallback, - ) -> None: - if event.type == RuntimeEventType.STATE: - if event.state == "on-call": - transition(call_session, CallEvent.REMOTE_ACCEPTED) - runtime.remote_accepted(call_session.bridge_ready) - - if call_session.state == CallState.IN_CALL: - recorder.start() - await voice.greet_once() - elif event.state == "idle": - transition(call_session, CallEvent.REMOTE_IDLE, remote_reason=event.reason) - elif event.type == RuntimeEventType.BRIDGE_READY: - transition(call_session, CallEvent.BRIDGE_READY) - runtime.bridge_negotiated() - - if call_session.state == CallState.IN_CALL: - recorder.start() - await voice.greet_once() - elif event.type == RuntimeEventType.END_CALL: - if event.reason is None: - # The library reports connection-lost without a server cause. - raise RuntimeError("callsRtc connection lost before audio bridge") - - transition(call_session, CallEvent.REMOTE_IDLE, remote_reason=event.reason) - elif event.type == RuntimeEventType.BRIDGE_FAILED: - raise event.error or RuntimeError("Audio bridge failed") - elif event.type == RuntimeEventType.VOICE_FAILED: - raise event.error or RuntimeError("OpenAI Realtime failed") - elif event.type == RuntimeEventType.DISCONNECT: - if event.permanent: - raise RuntimeError("callsRtc connection permanently closed") - # The library reconnects and rebuilds an active audio bridge. - elif event.type == RuntimeEventType.SHUTDOWN: - transition(call_session, CallEvent.SHUTDOWN_REQUESTED, error_code="shutdown") - await self._safe_hang_up(api) - - async def _handle_timeout( - self, - runtime: CallRuntime, - api: GreenAPI, - call_session: CallSession, - transition: TransitionCallback, - ) -> None: - timer = runtime.expired_timer(call_session.state) - - if timer == RuntimeTimer.RING: - transition(call_session, CallEvent.RING_TIMER_EXPIRED) - await self._hang_up(api, call_session, transition) - elif timer == RuntimeTimer.TALK: - transition(call_session, CallEvent.TALK_TIMER_EXPIRED) - await self._hang_up(api, call_session, transition) - elif timer == RuntimeTimer.BRIDGE: - transition( - call_session, CallEvent.INTERNAL_ERROR, - error_code="bridge_negotiation_timeout", - ) - - await self._safe_hang_up(api) - - @staticmethod - async def _hang_up( - api: GreenAPI, - call_session: CallSession, - transition: TransitionCallback, - ) -> None: - try: - await api.voip.hangUpAsync() - except Exception as error: - transition( - call_session, CallEvent.INTERNAL_ERROR, - error_code=f"hangup_{type(error).__name__}", - ) - - return - - transition(call_session, CallEvent.HANGUP_CONFIRMED) - - @staticmethod - async def _safe_hang_up(api: GreenAPI) -> None: - with suppress(Exception): - await api.voip.hangUpAsync() + await finish_cleanup(api.__aexit__(None, None, None)) diff --git a/whatsapp_chatbot_python/calls/state_machine.py b/whatsapp_chatbot_python/calls/state_machine.py index 91fc7d4..e8d812a 100644 --- a/whatsapp_chatbot_python/calls/state_machine.py +++ b/whatsapp_chatbot_python/calls/state_machine.py @@ -1,194 +1,183 @@ -from __future__ import annotations -from collections.abc import Callable +"""Pure transition planning followed by a single, explicit commit.""" from dataclasses import dataclass +from datetime import datetime +from typing import Callable from .models import ( - CallEndReason, - CallEvent, + utc_now, + CallDirection, + CallEndReason as Reason, + CallEvent as Event, CallSession, - CallState, + CallState as State, StateTransition, TERMINAL_STATES, - TransitionOutcome, - utc_now, ) -TransitionTarget = CallState | Callable[[CallSession], CallState] +_FINAL_STATE = { + Reason.RING_TIMEOUT: State.RING_TIMEOUT, + Reason.TALK_TIMEOUT: State.TALK_TIMEOUT, + Reason.SHUTDOWN: State.CANCELLED, + Reason.USER_CANCELLED: State.CANCELLED, +} -@dataclass(frozen=True, slots=True) -class TransitionRule: - target: TransitionTarget +@dataclass(frozen=True) +class Change: + target: State + reason: Reason | None = None + queued: bool = False + started: bool = False + answered: bool = False + bridge: bool = False + accepted: bool = False - def resolve(self, session: CallSession) -> CallState: - if callable(self.target): - return self.target(session) - return self.target +Resolver = Callable[[CallSession], Change] -def _bridge_ready_while_ringing(session: CallSession) -> CallState: - if session.remote_accepted: - return CallState.IN_CALL +@dataclass(frozen=True) +class TransitionRule: + change: Change | Resolver + direction: CallDirection | None = None - return CallState.RINGING + def resolve(self, session: CallSession) -> Change: + return self.change(session) if callable(self.change) else self.change -def _remote_accepted_while_ringing(session: CallSession) -> CallState: - if session.bridge_ready: - return CallState.IN_CALL +def _dequeue(session: CallSession) -> Change: + target = { + CallDirection.INCOMING: State.RINGING, + CallDirection.OUTGOING: State.DIALING, + }[session.direction] - return CallState.CONNECTING + return Change(target, started=True) -def _ending_target(session: CallSession) -> CallState: - return { - CallEndReason.RING_TIMEOUT: CallState.RING_TIMEOUT, - CallEndReason.TALK_TIMEOUT: CallState.TALK_TIMEOUT, - }.get(session.end_reason, CallState.FAILED) +def _answer(session: CallSession) -> Change: + return Change( + State.IN_CALL if session.bridge_ready else State.CONNECTING, + answered=True, + ) -_TRANSITION_RULES: dict[tuple[CallState, CallEvent], TransitionRule] = { - (CallState.TRIGGERED, CallEvent.ENQUEUED): TransitionRule(CallState.QUEUED), - (CallState.QUEUED, CallEvent.DEQUEUED): TransitionRule(CallState.DIALING), - (CallState.QUEUED, CallEvent.CANCEL_REQUESTED): TransitionRule(CallState.CANCELLED), - (CallState.DIALING, CallEvent.DIAL_ACCEPTED): TransitionRule(CallState.RINGING), - (CallState.DIALING, CallEvent.REMOTE_IDLE): TransitionRule(CallState.REJECTED), +def _bridge(session: CallSession) -> Change: + target = State.IN_CALL if session.remote_accepted else session.state + return Change(target, bridge=True) - (CallState.RINGING, CallEvent.BRIDGE_READY): TransitionRule( - _bridge_ready_while_ringing - ), - (CallState.RINGING, CallEvent.REMOTE_ACCEPTED): TransitionRule( - _remote_accepted_while_ringing - ), +def _idle(session: CallSession) -> Change: + if session.remote_accepted: + return Change( + State.REMOTE_ENDED, + Reason.REMOTE_HANGUP, + ) - (CallState.RINGING, CallEvent.REMOTE_IDLE): TransitionRule(CallState.REJECTED), + return Change(State.REJECTED, Reason.REMOTE_REJECTED) - (CallState.RINGING, CallEvent.RING_TIMER_EXPIRED): TransitionRule( - CallState.ENDING - ), - (CallState.CONNECTING, CallEvent.BRIDGE_READY): TransitionRule(CallState.IN_CALL), +def _hangup(session: CallSession) -> Change: + return Change(_FINAL_STATE.get(session.end_reason, State.FAILED)) - # The remote party already answered; report a completed call even if the - # bridge never became ready. No recording is created until IN_CALL. - (CallState.CONNECTING, CallEvent.REMOTE_IDLE): TransitionRule( - CallState.REMOTE_ENDED - ), - (CallState.CONNECTING, CallEvent.TALK_TIMER_EXPIRED): TransitionRule( - CallState.ENDING - ), +def _rules() -> dict[tuple[State, Event], TransitionRule]: + rules = { + (State.TRIGGERED, Event.ENQUEUED): TransitionRule(Change(State.QUEUED, queued=True)), + (State.QUEUED, Event.DEQUEUED): TransitionRule(_dequeue), + (State.DIALING, Event.DIAL_ACCEPTED): TransitionRule(Change(State.RINGING)), + (State.ENDING, Event.HANGUP_CONFIRMED): TransitionRule(_hangup), + (State.IN_CALL, Event.TALK_TIMER_EXPIRED): TransitionRule(Change(State.ENDING, Reason.TALK_TIMEOUT)), + } - (CallState.IN_CALL, CallEvent.REMOTE_IDLE): TransitionRule(CallState.REMOTE_ENDED), - (CallState.IN_CALL, CallEvent.TALK_TIMER_EXPIRED): TransitionRule(CallState.ENDING), - (CallState.ENDING, CallEvent.HANGUP_CONFIRMED): TransitionRule(_ending_target), - (CallState.ENDING, CallEvent.REMOTE_IDLE): TransitionRule(_ending_target), -} + active = (State.DIALING, State.RINGING, State.CONNECTING, State.IN_CALL) + + for state in active: + rules[state, Event.BRIDGE_READY] = TransitionRule(_bridge) + rules[state, Event.REMOTE_IDLE] = TransitionRule(_idle) + rules[state, Event.INCOMING_ACCEPTED] = TransitionRule(Change(state, accepted=True), CallDirection.INCOMING) + for state in (State.DIALING, State.RINGING): + rules[state, Event.REMOTE_ACCEPTED] = TransitionRule(_answer) + rules[state, Event.RING_TIMER_EXPIRED] = TransitionRule(Change(State.ENDING, Reason.RING_TIMEOUT)) -for nonterminal_state in CallState: - if nonterminal_state in TERMINAL_STATES: - continue + for state in set(State) - TERMINAL_STATES: + rules[state, Event.INTERNAL_ERROR] = TransitionRule(Change(State.FAILED, Reason.ERROR)) + rules[state, Event.SHUTDOWN_REQUESTED] = TransitionRule(Change(State.CANCELLED, Reason.SHUTDOWN)) + rules[state, Event.CANCEL_REQUESTED] = TransitionRule(Change(State.CANCELLED, Reason.USER_CANCELLED)) - for failure_event in ( - CallEvent.INTERNAL_ERROR, - CallEvent.SHUTDOWN_REQUESTED, - ): - _TRANSITION_RULES[(nonterminal_state, failure_event)] = TransitionRule( - CallState.FAILED + for state in (State.TRIGGERED, State.QUEUED, State.RINGING): + rules[state, Event.INCOMING_EXPIRED] = TransitionRule( + Change(State.REJECTED, Reason.INCOMING_EXPIRED), + CallDirection.INCOMING, ) + rules[state, Event.CALLER_LEFT] = TransitionRule( + Change(State.REJECTED, Reason.CALLER_LEFT), + CallDirection.INCOMING, + ) -class CallStateMachine: - """ - Apply the formal call transition table and its associated session data. + return rules - Duplicate, late and out-of-order events are intentionally ignored. An applied - event may update session data without changing state, for example an early - BRIDGE_READY event received while the remote side is still ringing. - """ +_RULES = _rules() + + +class CallStateMachine: def apply( self, session: CallSession, - event: CallEvent, + event: Event, *, remote_reason: str | None = None, error_code: str | None = None, ) -> StateTransition: - previous = session.state - - if previous in TERMINAL_STATES: - return self._ignored(previous, event) - - rule = _TRANSITION_RULES.get((previous, event)) + rule = self._find_rule(session, event) if rule is None: - return self._ignored(previous, event) + return StateTransition(session.state, session.state, event, False) - if remote_reason is not None: - session.remote_reason = remote_reason + change = rule.resolve(session) - if error_code is not None: - session.error_code = error_code + return self._commit(session, event, change, utc_now(), remote_reason, error_code) - self._apply_event_data(session, event) + @staticmethod + def _find_rule(session: CallSession, event: Event) -> TransitionRule | None: + rule = _RULES.get((session.state, event)) - target = rule.resolve(session) + if rule is None or rule.direction not in (None, session.direction): + return None - session.state = target + return rule - if target in TERMINAL_STATES: - session.ended_at = utc_now() + @staticmethod + def _commit( + session: CallSession, + event: Event, + change: Change, + now: datetime, remote_reason: str | None, + error_code: str | None, + ) -> StateTransition: + previous = session.state + session.state = change.target + session.end_reason = change.reason or session.end_reason + session.remote_reason = remote_reason if remote_reason is not None else session.remote_reason + session.error_code = error_code if error_code is not None else session.error_code - return StateTransition( - previous=previous, - current=target, - event=event, - outcome=TransitionOutcome.APPLIED, - ) + if change.queued: + session.queued_at = session.queued_at or now - @staticmethod - def _ignored(previous: CallState, event: CallEvent) -> StateTransition: - return StateTransition( - previous=previous, - current=previous, - event=event, - outcome=TransitionOutcome.IGNORED, - ) + if change.started: + session.started_at = session.started_at or now - @staticmethod - def _apply_event_data(session: CallSession, event: CallEvent) -> None: - now = utc_now() - - if event == CallEvent.ENQUEUED: - session.queued_at = now - elif event == CallEvent.DEQUEUED: - session.dialing_at = now - elif event == CallEvent.CANCEL_REQUESTED: - session.end_reason = CallEndReason.USER_CANCELLED - elif event == CallEvent.REMOTE_ACCEPTED: + if change.answered: session.remote_accepted = True session.answered_at = session.answered_at or now - elif event == CallEvent.BRIDGE_READY: - session.bridge_ready = True - elif event == CallEvent.RING_TIMER_EXPIRED: - session.end_reason = CallEndReason.RING_TIMEOUT - elif event == CallEvent.TALK_TIMER_EXPIRED: - session.end_reason = CallEndReason.TALK_TIMEOUT - elif event == CallEvent.REMOTE_IDLE: - CallStateMachine._apply_remote_idle(session) - elif event == CallEvent.SHUTDOWN_REQUESTED: - session.end_reason = CallEndReason.SHUTDOWN - elif event == CallEvent.INTERNAL_ERROR: - session.end_reason = CallEndReason.ERROR - @staticmethod - def _apply_remote_idle(session: CallSession) -> None: - if session.state in {CallState.DIALING, CallState.RINGING}: - session.end_reason = CallEndReason.REMOTE_REJECTED - elif session.state in {CallState.CONNECTING, CallState.IN_CALL}: - session.end_reason = CallEndReason.REMOTE_HANGUP + session.bridge_ready |= change.bridge + session.incoming_accept_completed |= change.accepted + + if session.finished: + session.ended_at = session.ended_at or now + + return StateTransition(previous, session.state, event, True) diff --git a/whatsapp_chatbot_python/manager/handler.py b/whatsapp_chatbot_python/manager/handler.py index 09061f3..2c2958f 100644 --- a/whatsapp_chatbot_python/manager/handler.py +++ b/whatsapp_chatbot_python/manager/handler.py @@ -1,14 +1,14 @@ import json import logging from abc import ABC, abstractmethod -from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union +from typing import Any, Callable, Dict, Generic, List, Optional, TYPE_CHECKING, TypeVar, Union from whatsapp_api_client_python.response import Response from ..filters import filters as event_filters if TYPE_CHECKING: - from .observer import Observer + from .observer import AbstractObserver from .state import AbstractStateManager from ..bot import GreenAPI @@ -167,27 +167,28 @@ def answer_with_poll( ) HandlerType = Callable[[Notification], Any] +NotificationT = TypeVar("NotificationT", bound=Notification) -class AbstractHandler(ABC): - handler: HandlerType +class AbstractHandler(ABC, Generic[NotificationT]): + handler: Callable[[NotificationT], Any] filters: Dict[str, Any] @abstractmethod - def check_event(self, notification: Notification) -> bool: + def check_event(self, notification: NotificationT) -> bool: pass @abstractmethod - def execute_handler(self, observer: "Observer") -> bool: + def execute_handler(self, observer: "AbstractObserver[NotificationT]") -> bool: pass -class Handler(AbstractHandler): - def __init__(self, handler: HandlerType, **filters: Any): +class Handler(AbstractHandler[NotificationT], Generic[NotificationT]): + def __init__(self, handler: Callable[[NotificationT], Any], **filters: Any): self.handler = handler self.filters = filters - def check_event(self, notification: Notification) -> bool: + def check_event(self, notification: NotificationT) -> bool: for filter_name in self.filters.keys(): filter_ = event_filters.get(filter_name) if filter_: @@ -198,10 +199,8 @@ def check_event(self, notification: Notification) -> bool: return True - def execute_handler(self, observer: "Observer") -> bool: - notification = Notification( - observer.event, observer.router.api, observer.state_manager - ) + def execute_handler(self, observer: "AbstractObserver[NotificationT]") -> bool: + notification = observer.make_notification() filters = json.dumps(self.filters, ensure_ascii=False) diff --git a/whatsapp_chatbot_python/manager/incoming_call.py b/whatsapp_chatbot_python/manager/incoming_call.py new file mode 100644 index 0000000..393220e --- /dev/null +++ b/whatsapp_chatbot_python/manager/incoming_call.py @@ -0,0 +1,119 @@ +"""Typed access to an incoming-call webhook without VoIP dependencies.""" + +from dataclasses import dataclass, field +from math import isfinite +from typing import Any, Dict, Optional, TYPE_CHECKING + +from .handler import Handler, Notification + +if TYPE_CHECKING: + from .state import AbstractStateManager + from ..bot import GreenAPI + + +MIN_SECONDS = 946_684_800 # 2000-01-01 UTC + +MAX_SECONDS = 4_102_444_800 # 2100-01-01 UTC, exclusive + + +def _optional_text(value: Any) -> Optional[str]: + return value if isinstance(value, str) and value else None + + +def _unix_seconds(value: Any) -> Optional[float]: + """Accept seconds, milliseconds or microseconds for dates in 2000–2100.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + + try: + value = float(value) + except OverflowError: + return None + + if not isfinite(value) or value <= 0: + return None + + # Keep the supported units and plausible range explicit. A larger value is + # not automatically another time unit (for example, nanoseconds). + if MIN_SECONDS <= value < MAX_SECONDS: + return value + + if 1_000 * MIN_SECONDS <= value < 1_000 * MAX_SECONDS: + return value / 1_000 + + if 1_000_000 * MIN_SECONDS <= value < 1_000_000 * MAX_SECONDS: + return value / 1_000_000 + + return None + + +@dataclass(frozen=True) +class IncomingCall: + sender: Optional[str] + call_id: Optional[str] + status: Optional[str] + timestamp: Optional[float] + raw_event: Optional[Dict[str, Any]] = field(default=None, compare=False, repr=False) + + @property + def chat_id(self) -> Optional[str]: + """The caller's address is the chat address for a direct call.""" + return self.sender + + @classmethod + def from_event(cls, event: Dict[str, Any]) -> "IncomingCall": + return cls( + sender=_optional_text(event.get("from")), + call_id=_optional_text(event.get("idMessage")), + status=_optional_text(event.get("status")), + timestamp=_unix_seconds(event.get("timestamp")), + raw_event=event, + ) + + +class IncomingCallNotification(Notification): + """Call fields plus the original webhook and the user's state manager.""" + + call: IncomingCall + + def __init__( + self, event: Dict[str, Any], api: "GreenAPI", + state_manager: "AbstractStateManager", + ) -> None: + super().__init__(event, api, state_manager) + self.call = IncomingCall.from_event(event) + + @property + def raw_event(self) -> dict: + return self.event + + def get_chat(self) -> Optional[str]: + return self.call.chat_id + + def get_sender(self) -> Optional[str]: + return self.call.sender + + def get_id_message(self) -> Optional[str]: + return self.call.call_id + + def get_message_text(self) -> None: + return None + + def get_message_data(self) -> None: + return None + + +class IncomingCallHandler(Handler[IncomingCallNotification]): + """Keep status filtering local to incoming calls.""" + + def check_event(self, notification: IncomingCallNotification) -> bool: + accepted_statuses = self.filters.get("status") + + if accepted_statuses is not None: + if notification.call.status not in accepted_statuses: + return False + + return super().check_event(notification) + + +__all__ = ["IncomingCall", "IncomingCallNotification", "IncomingCallHandler"] diff --git a/whatsapp_chatbot_python/manager/observer.py b/whatsapp_chatbot_python/manager/observer.py index 13fda71..2ec61b7 100644 --- a/whatsapp_chatbot_python/manager/observer.py +++ b/whatsapp_chatbot_python/manager/observer.py @@ -1,26 +1,34 @@ import logging from abc import ABC, abstractmethod -from typing import Any, Callable, List, TYPE_CHECKING +from typing import Any, Callable, Generic, List, Optional, TYPE_CHECKING, Type, TypeVar -from .handler import AbstractHandler, Handler, HandlerType +from .handler import AbstractHandler, Handler, Notification, NotificationT +from .incoming_call import IncomingCallHandler, IncomingCallNotification from .state import AbstractStateManager, StateManager if TYPE_CHECKING: from .router import Router +ReturnT = TypeVar("ReturnT") -class AbstractObserver(ABC): + +class AbstractObserver(ABC, Generic[NotificationT]): + router: "Router" event: dict - handlers: List[AbstractHandler] + handlers: List[AbstractHandler[NotificationT]] state_manager: AbstractStateManager + @abstractmethod + def make_notification(self) -> NotificationT: + pass + def update_event(self, event: dict) -> None: self.event = event self.propagate_event() @abstractmethod - def add_handler(self, handler: HandlerType, **filters: Any) -> None: + def add_handler(self, handler: Callable[[NotificationT], Any], **filters: Any) -> None: pass @abstractmethod @@ -28,15 +36,21 @@ def propagate_event(self) -> None: pass -class Observer(AbstractObserver): - def __init__(self, router: "Router"): +class _TypedObserver(AbstractObserver[NotificationT], Generic[NotificationT]): + def __init__( + self, router: "Router", notification_type: Type[NotificationT], + state_manager: Optional[AbstractStateManager] = None, + ): self.router = router - self.event = {} - self.handlers = [] - self.state_manager = StateManager() + self.handlers: List[AbstractHandler[NotificationT]] = [] + self.state_manager = state_manager if state_manager is not None else StateManager() + self._notification_type = notification_type + + def make_notification(self) -> NotificationT: + return self._notification_type(self.event, self.router.api, self.state_manager) - def add_handler(self, handler: HandlerType, **filters: Any) -> None: + def add_handler(self, handler: Callable[[NotificationT], Any], **filters: Any) -> None: self.handlers.append(Handler(handler, **filters)) def propagate_event(self) -> None: @@ -65,17 +79,27 @@ def propagate_event(self) -> None: ) ) - def __call__(self, **filters: Any) -> Callable[[HandlerType], HandlerType]: - def wrapper(handler: HandlerType) -> HandlerType: + def __call__( + self, **filters: Any, + ) -> Callable[[Callable[[NotificationT], ReturnT]], Callable[[NotificationT], ReturnT]]: + def wrapper( + handler: Callable[[NotificationT], ReturnT], + ) -> Callable[[NotificationT], ReturnT]: self.add_handler(handler, **filters) - return handler return wrapper +class Observer(_TypedObserver[Notification]): + def __init__( + self, router: "Router", state_manager: Optional[AbstractStateManager] = None, + ): + super().__init__(router, Notification, state_manager) + + class ButtonObserver(Observer): - def add_handler(self, handler: HandlerType, **filters: Any) -> None: + def add_handler(self, handler: Callable[[Notification], Any], **filters: Any) -> None: message_types = [ "buttonsResponseMessage", "templateButtonsReplyMessage", @@ -89,21 +113,62 @@ def add_handler(self, handler: HandlerType, **filters: Any) -> None: self.router.message.add_handler(handler, **filters) +class IncomingCallObserver(_TypedObserver[IncomingCallNotification]): + """Incoming-call notifications share message state for the same sender.""" + + _allowed_filters = frozenset({"status", "from_sender", "from_chat", "state"}) + + def __init__( + self, router: "Router", state_manager: Optional[AbstractStateManager] = None, + ): + super().__init__( + router, IncomingCallNotification, state_manager, + ) + + def add_handler( + self, handler: Callable[[IncomingCallNotification], Any], **filters: Any, + ) -> None: + unsupported = set(filters) - self._allowed_filters + if unsupported: + raise ValueError( + "Unsupported incoming-call filters: " + ", ".join(sorted(unsupported)) + ) + + if "status" in filters: + status = filters["status"] + + if isinstance(status, str): + statuses = [status] + elif isinstance(status, (list, tuple)): + statuses = list(status) + else: + statuses = [] + + if not statuses or not all(isinstance(value, str) and value for value in statuses): + raise ValueError( + "Incoming-call status must be a string or a nonempty list of strings" + ) + + filters["status"] = statuses + + self.handlers.append(IncomingCallHandler(handler, **filters)) + + class PollObserver(Observer): - def add_handler(self, handler: HandlerType, **filters: Any) -> None: + def add_handler(self, handler: Callable[[Notification], Any], **filters: Any) -> None: filters["type_message"] = "pollMessage" self.router.message.add_handler(handler, **filters) class PollUpdateObserver(Observer): - def add_handler(self, handler: HandlerType, **filters: Any) -> None: + def add_handler(self, handler: Callable[[Notification], Any], **filters: Any) -> None: filters["type_message"] = "pollUpdateMessage" self.router.message.add_handler(handler, **filters) def add_handler_with_stanza( - self, handler: HandlerType, stanza: str, **filters: Any + self, handler: Callable[[Notification], Any], stanza: str, **filters: Any ) -> None: filters.update({ "stanza": stanza, @@ -116,6 +181,7 @@ def add_handler_with_stanza( __all__ = [ "AbstractObserver", "ButtonObserver", + "IncomingCallObserver", "Observer", "PollObserver", "PollUpdateObserver" diff --git a/whatsapp_chatbot_python/manager/router.py b/whatsapp_chatbot_python/manager/router.py index 4604d2c..c6e912c 100644 --- a/whatsapp_chatbot_python/manager/router.py +++ b/whatsapp_chatbot_python/manager/router.py @@ -1,10 +1,11 @@ import json import logging -from typing import Dict, TYPE_CHECKING +from typing import Any, Dict, TYPE_CHECKING from .observer import ( AbstractObserver, ButtonObserver, + IncomingCallObserver, Observer, PollObserver, PollUpdateObserver @@ -19,18 +20,21 @@ def __init__(self, api: "GreenAPI", logger: logging.Logger): self.api = api self.logger = logger - self.message: AbstractObserver = Observer(self) - self.outgoing_message: AbstractObserver = Observer(self) - self.outgoing_api_message: AbstractObserver = Observer(self) - self.outgoing_message_status: AbstractObserver = Observer(self) - self.incoming_call: AbstractObserver = Observer(self) + self.message: Observer = Observer(self) + self.outgoing_message: Observer = Observer(self) + self.outgoing_api_message: Observer = Observer(self) + self.outgoing_message_status: Observer = Observer(self) - self.buttons: AbstractObserver = ButtonObserver(self) + self.incoming_call: IncomingCallObserver = IncomingCallObserver( + self, state_manager=self.message.state_manager, + ) + + self.buttons: ButtonObserver = ButtonObserver(self) self.poll_message: PollObserver = PollObserver(self) self.poll_update_message: PollUpdateObserver = PollUpdateObserver(self) - self.observers: Dict[str, AbstractObserver] = { + self.observers: Dict[str, AbstractObserver[Any]] = { "incomingMessageReceived": self.message, "outgoingMessageReceived": self.outgoing_message, "outgoingAPIMessageReceived": self.outgoing_api_message,