whatsapp-chatbot-python 1.0.0__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/PKG-INFO +54 -12
  2. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/README.md +53 -11
  3. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/setup.py +1 -1
  4. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/tests/test_calls_imports.py +7 -5
  5. whatsapp_chatbot_python-1.1.0/tests/test_incoming_call_notifications.py +112 -0
  6. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python/__init__.py +3 -0
  7. whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/__init__.py +37 -0
  8. whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/audio.py +279 -0
  9. whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/call_runner.py +750 -0
  10. whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/greenapi.py +294 -0
  11. whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/models.py +483 -0
  12. whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/openai.py +297 -0
  13. whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/playback_state_machine.py +257 -0
  14. whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/recorder.py +320 -0
  15. whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/service.py +180 -0
  16. whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/state_machine.py +183 -0
  17. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python/manager/handler.py +12 -13
  18. whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/manager/incoming_call.py +119 -0
  19. whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/manager/observer.py +188 -0
  20. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python/manager/router.py +12 -8
  21. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python.egg-info/PKG-INFO +54 -12
  22. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python.egg-info/SOURCES.txt +7 -4
  23. whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/__init__.py +0 -34
  24. whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/contracts.py +0 -28
  25. whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/models.py +0 -121
  26. whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/playback_state_machine.py +0 -46
  27. whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/realtime_voice.py +0 -520
  28. whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/recording.py +0 -151
  29. whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/runtime.py +0 -136
  30. whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/service.py +0 -439
  31. whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/state_machine.py +0 -194
  32. whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/manager/observer.py +0 -122
  33. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/LICENSE +0 -0
  34. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/setup.cfg +0 -0
  35. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/tests/test_manager.py +0 -0
  36. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python/bot.py +0 -0
  37. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python/filters.py +0 -0
  38. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python/manager/__init__.py +0 -0
  39. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python/manager/state.py +0 -0
  40. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python.egg-info/dependency_links.txt +0 -0
  41. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python.egg-info/requires.txt +0 -0
  42. {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whatsapp-chatbot-python
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: This library helps you easily create a Python chatbot with WhatsApp API.
5
5
  Home-page: https://github.com/green-api/whatsapp-chatbot-python
6
6
  Author: GREEN API
@@ -593,20 +593,25 @@ credentials:
593
593
  ```python
594
594
  import asyncio
595
595
  import logging
596
- import os
597
596
 
598
597
  from whatsapp_chatbot_python.calls import (
599
598
  CallEvent, CallSession, CallStateMachine,
600
599
  )
601
600
  from whatsapp_chatbot_python.calls.service import WhatsAppCallService
602
601
 
602
+ id_instance = "ID_INSTANCE"
603
+ api_token_instance = "API_TOKEN"
604
+ openai_api_key = "OPENAI_KEY"
605
+ realtime_model = "gpt-realtime-2.1"
606
+ realtime_voice = "marin"
607
+
603
608
  service = WhatsAppCallService(
604
609
  api_url="https://api.green-api.com",
605
- id_instance=os.environ["ID_INSTANCE"],
606
- api_token_instance=os.environ["API_TOKEN_INSTANCE"],
607
- openai_api_key=os.environ["OPENAI_API_KEY"],
608
- realtime_model=os.environ["REALTIME_MODEL"],
609
- realtime_voice=os.environ["REALTIME_VOICE"],
610
+ id_instance=id_instance,
611
+ api_token_instance=api_token_instance,
612
+ openai_api_key=openai_api_key,
613
+ realtime_model=realtime_model,
614
+ realtime_voice=realtime_voice,
610
615
  ring_timeout_seconds=30,
611
616
  talk_timeout_seconds=120,
612
617
  shutdown_timeout_seconds=10,
@@ -623,18 +628,55 @@ async def call_once():
623
628
  fsm.apply(session, CallEvent.ENQUEUED)
624
629
  fsm.apply(session, CallEvent.DEQUEUED)
625
630
  result = await service.execute(session, fsm.apply)
626
- try:
631
+ with result:
627
632
  print(session.state, session.end_reason, session.error_code)
628
633
  if result.recording_path is not None:
629
634
  print("MP3:", result.recording_path)
630
- # Upload or copy the recording here, before removing it.
631
- finally:
632
- if result.recording_path is not None:
633
- result.recording_path.unlink(missing_ok=True)
635
+ # Upload or copy it here; the context removes the temporary file.
634
636
 
635
637
  asyncio.run(call_once())
636
638
  ```
637
639
 
640
+ `with result:` removes an unclaimed MP3 after use. Call `result.take_recording()`
641
+ to keep the file. `CallOptions(input_audio=InputAudioOptions(...))` configures the
642
+ bounded queue from RTC audio to the voice model; a full queue fails the call.
643
+ Cleanup timeouts mean resource closure is unconfirmed, not complete. The service
644
+ then reports `healthy=False` and rejects subsequent calls. The installed RTC SDK
645
+ can leave signaling open if its own close operation never finishes; restart the
646
+ worker after investigating such a failure.
647
+
648
+ ### Accept an incoming call
649
+
650
+ The application owns its queue, offer deduplication and notification parsing. An incoming offer can share the same queue
651
+ and worker as outgoing calls. Create its deadline when the offer arrives, using the time remaining from the offer
652
+ timestamp. For example, a 60-second application limit becomes `monotonic() + remaining_seconds`; the library has no
653
+ fixed incoming ring limit. The deadline includes time spent in the queue and in call setup. Coordinators can type their
654
+ executor as `IncomingCallExecutor`, which extends the existing `CallExecutor` with the two incoming call-control
655
+ methods.
656
+
657
+ ```python
658
+ from time import monotonic
659
+ from whatsapp_chatbot_python.calls import CallEvent, CallSession, CallStateMachine
660
+
661
+ session = CallSession.incoming(
662
+ sender_id=caller_id,
663
+ call_id=call_id,
664
+ language="en",
665
+ incoming_deadline=monotonic() + remaining_seconds,
666
+ greeting_text="Hello! This is a WhatsApp demo call. What would you like to discuss?",
667
+ )
668
+ fsm = CallStateMachine()
669
+ fsm.apply(session, CallEvent.ENQUEUED)
670
+ app_queue.put(session)
671
+
672
+ if monotonic() >= session.incoming_deadline:
673
+ fsm.apply(session, CallEvent.INCOMING_EXPIRED)
674
+ await service.reject_incoming(session.call_id, session.sender_id)
675
+ else:
676
+ fsm.apply(session, CallEvent.DEQUEUED)
677
+ result = await service.execute(session, transition_under_app_lock)
678
+ ```
679
+
638
680
  ## Service methods documentation
639
681
 
640
682
  [Service methods documentation](https://green-api.com/en/docs/api/)
@@ -541,20 +541,25 @@ credentials:
541
541
  ```python
542
542
  import asyncio
543
543
  import logging
544
- import os
545
544
 
546
545
  from whatsapp_chatbot_python.calls import (
547
546
  CallEvent, CallSession, CallStateMachine,
548
547
  )
549
548
  from whatsapp_chatbot_python.calls.service import WhatsAppCallService
550
549
 
550
+ id_instance = "ID_INSTANCE"
551
+ api_token_instance = "API_TOKEN"
552
+ openai_api_key = "OPENAI_KEY"
553
+ realtime_model = "gpt-realtime-2.1"
554
+ realtime_voice = "marin"
555
+
551
556
  service = WhatsAppCallService(
552
557
  api_url="https://api.green-api.com",
553
- id_instance=os.environ["ID_INSTANCE"],
554
- api_token_instance=os.environ["API_TOKEN_INSTANCE"],
555
- openai_api_key=os.environ["OPENAI_API_KEY"],
556
- realtime_model=os.environ["REALTIME_MODEL"],
557
- realtime_voice=os.environ["REALTIME_VOICE"],
558
+ id_instance=id_instance,
559
+ api_token_instance=api_token_instance,
560
+ openai_api_key=openai_api_key,
561
+ realtime_model=realtime_model,
562
+ realtime_voice=realtime_voice,
558
563
  ring_timeout_seconds=30,
559
564
  talk_timeout_seconds=120,
560
565
  shutdown_timeout_seconds=10,
@@ -571,18 +576,55 @@ async def call_once():
571
576
  fsm.apply(session, CallEvent.ENQUEUED)
572
577
  fsm.apply(session, CallEvent.DEQUEUED)
573
578
  result = await service.execute(session, fsm.apply)
574
- try:
579
+ with result:
575
580
  print(session.state, session.end_reason, session.error_code)
576
581
  if result.recording_path is not None:
577
582
  print("MP3:", result.recording_path)
578
- # Upload or copy the recording here, before removing it.
579
- finally:
580
- if result.recording_path is not None:
581
- result.recording_path.unlink(missing_ok=True)
583
+ # Upload or copy it here; the context removes the temporary file.
582
584
 
583
585
  asyncio.run(call_once())
584
586
  ```
585
587
 
588
+ `with result:` removes an unclaimed MP3 after use. Call `result.take_recording()`
589
+ to keep the file. `CallOptions(input_audio=InputAudioOptions(...))` configures the
590
+ bounded queue from RTC audio to the voice model; a full queue fails the call.
591
+ Cleanup timeouts mean resource closure is unconfirmed, not complete. The service
592
+ then reports `healthy=False` and rejects subsequent calls. The installed RTC SDK
593
+ can leave signaling open if its own close operation never finishes; restart the
594
+ worker after investigating such a failure.
595
+
596
+ ### Accept an incoming call
597
+
598
+ The application owns its queue, offer deduplication and notification parsing. An incoming offer can share the same queue
599
+ and worker as outgoing calls. Create its deadline when the offer arrives, using the time remaining from the offer
600
+ timestamp. For example, a 60-second application limit becomes `monotonic() + remaining_seconds`; the library has no
601
+ fixed incoming ring limit. The deadline includes time spent in the queue and in call setup. Coordinators can type their
602
+ executor as `IncomingCallExecutor`, which extends the existing `CallExecutor` with the two incoming call-control
603
+ methods.
604
+
605
+ ```python
606
+ from time import monotonic
607
+ from whatsapp_chatbot_python.calls import CallEvent, CallSession, CallStateMachine
608
+
609
+ session = CallSession.incoming(
610
+ sender_id=caller_id,
611
+ call_id=call_id,
612
+ language="en",
613
+ incoming_deadline=monotonic() + remaining_seconds,
614
+ greeting_text="Hello! This is a WhatsApp demo call. What would you like to discuss?",
615
+ )
616
+ fsm = CallStateMachine()
617
+ fsm.apply(session, CallEvent.ENQUEUED)
618
+ app_queue.put(session)
619
+
620
+ if monotonic() >= session.incoming_deadline:
621
+ fsm.apply(session, CallEvent.INCOMING_EXPIRED)
622
+ await service.reject_incoming(session.call_id, session.sender_id)
623
+ else:
624
+ fsm.apply(session, CallEvent.DEQUEUED)
625
+ result = await service.execute(session, transition_under_app_lock)
626
+ ```
627
+
586
628
  ## Service methods documentation
587
629
 
588
630
  [Service methods documentation](https://green-api.com/en/docs/api/)
@@ -5,7 +5,7 @@ with open("README.md", encoding="UTF-8") as file:
5
5
 
6
6
  setup(
7
7
  name="whatsapp-chatbot-python",
8
- version="1.0.0",
8
+ version="1.1.0",
9
9
  description=(
10
10
  "This library helps you easily create"
11
11
  " a Python chatbot with WhatsApp API."
@@ -23,18 +23,20 @@ class BlockOptionalImports(importlib.abc.MetaPathFinder):
23
23
  raise ImportError("optional dependency blocked: " + fullname)
24
24
 
25
25
  sys.meta_path.insert(0, BlockOptionalImports())
26
- from whatsapp_chatbot_python import GreenAPIBot, Notification
26
+ from whatsapp_chatbot_python import GreenAPIBot, IncomingCall, IncomingCallNotification, Notification
27
+ assert IncomingCallNotification.__name__ == "IncomingCallNotification"
27
28
  assert "whatsapp_chatbot_python.calls" not in sys.modules
28
29
  ''')
29
30
 
30
31
  @unittest.skipIf(sys.version_info < (3, 11), "call contracts require Python 3.11+")
31
- def test_contracts_do_not_load_call_implementation(self):
32
+ def test_public_call_import_does_not_load_optional_media_dependencies(self):
32
33
  self.run_script('''
33
34
  import sys
34
- from whatsapp_chatbot_python.calls import CallSession, CallStateMachine
35
+ from whatsapp_chatbot_python.calls import CallDirection, CallSession, CallStateMachine, IncomingCallExecutor
35
36
  assert CallSession("sender", "chat", "en").state == "triggered"
36
- assert "whatsapp_chatbot_python.calls.service" not in sys.modules
37
- assert "whatsapp_chatbot_python.calls.realtime_voice" not in sys.modules
37
+ assert CallSession("sender", "chat", "en").direction == CallDirection.OUTGOING
38
+ assert IncomingCallExecutor.__name__ == "IncomingCallExecutor"
39
+ assert "whatsapp_chatbot_python.calls.service" in sys.modules
38
40
  assert "openai" not in sys.modules
39
41
  assert "aiortc" not in sys.modules
40
42
  assert "av" not in sys.modules
@@ -0,0 +1,112 @@
1
+ import logging
2
+ import unittest
3
+ from unittest.mock import Mock
4
+
5
+ from whatsapp_chatbot_python import IncomingCall, IncomingCallNotification, Notification
6
+ from whatsapp_chatbot_python.manager.router import Router
7
+
8
+
9
+ class IncomingCallNotificationTest(unittest.TestCase):
10
+ def setUp(self):
11
+ self.api = Mock()
12
+ self.router = Router(self.api, logging.getLogger(__name__))
13
+
14
+ @staticmethod
15
+ def event(status="offer", timestamp=1_700_000_000):
16
+ return {
17
+ "typeWebhook": "incomingCall", "from": "caller@c.us",
18
+ "idMessage": "call-1", "status": status, "timestamp": timestamp,
19
+ }
20
+
21
+ def test_typed_notification_keeps_original_webhook_and_message_methods_work(self):
22
+ received = []
23
+ self.router.incoming_call.add_handler(received.append)
24
+ event = self.event(status="future-status")
25
+
26
+ self.router.route_event(event)
27
+
28
+ notification = received[0]
29
+ self.assertIsInstance(notification, IncomingCallNotification)
30
+ self.assertIsInstance(notification, Notification)
31
+ self.assertIs(notification.event, event)
32
+ self.assertIs(notification.raw_event, event)
33
+ self.assertIs(notification.call.raw_event, event)
34
+ self.assertEqual(notification.call, IncomingCall("caller@c.us", "call-1", "future-status", 1_700_000_000.0))
35
+ self.assertEqual(notification.call.chat_id, "caller@c.us")
36
+ self.assertEqual((notification.sender, notification.chat, notification.get_id_message()),
37
+ ("caller@c.us", "caller@c.us", "call-1"))
38
+ self.assertIsNone(notification.message_text)
39
+ self.assertIsNone(notification.get_message_data())
40
+
41
+ notification.answer("Hello")
42
+ self.api.sending.sendMessage.assert_called_once_with(
43
+ "caller@c.us", "Hello", None, None, None, None,
44
+ )
45
+
46
+ def test_call_and_message_observers_share_state_only_with_each_other(self):
47
+ state_manager = self.router.message.state_manager
48
+ self.assertIs(self.router.incoming_call.state_manager, state_manager)
49
+ self.assertIsNot(self.router.outgoing_message.state_manager, state_manager)
50
+ state_manager.set_state("caller@c.us", "MENU")
51
+ state_manager.set_state_data("caller@c.us", {"language_code": "ru"})
52
+ received = []
53
+
54
+ @self.router.incoming_call(status="offer", state="MENU", from_sender="caller@c.us")
55
+ def on_offer(notification):
56
+ received.append(notification.state_manager.get_state_data(notification.call.sender))
57
+
58
+ self.router.route_event(self.event())
59
+ self.assertEqual(received, [{"language_code": "ru"}])
60
+
61
+ state_manager.update_state("caller@c.us", "DONE")
62
+ self.router.route_event(self.event())
63
+ self.assertEqual(len(received), 1)
64
+
65
+ def test_status_filter_accepts_one_or_many_values_and_unknown_status_is_routed(self):
66
+ offers, ended, other = [], [], []
67
+ self.router.incoming_call.add_handler(offers.append, status="offer")
68
+ self.router.incoming_call.add_handler(ended.append, status=["declined", "hungUp"])
69
+ self.router.incoming_call.add_handler(other.append)
70
+
71
+ for status in ("offer", "declined", "hungUp", "new-status"):
72
+ self.router.route_event(self.event(status=status))
73
+
74
+ self.assertEqual([item.call.status for item in offers], ["offer"])
75
+ self.assertEqual([item.call.status for item in ended], ["declined", "hungUp"])
76
+ self.assertEqual([item.call.status for item in other], ["new-status"])
77
+
78
+ def test_invalid_call_filters_fail_at_registration(self):
79
+ for filters in ({"status": None}, {"status": []}, {"status": 123},
80
+ {"status": ["offer", ""]}, {"command": "start"}):
81
+ with self.subTest(filters=filters), self.assertRaises(ValueError):
82
+ self.router.incoming_call.add_handler(lambda notification: None, **filters)
83
+ self.assertEqual(self.router.incoming_call.handlers, [])
84
+
85
+ def test_timestamps_and_missing_fields_are_safe(self):
86
+ for value, expected in ((1_700_000_000, 1_700_000_000.0),
87
+ (1_700_000_000_000, 1_700_000_000.0),
88
+ (1_700_000_000_000_000, 1_700_000_000.0),
89
+ (946_684_800, 946_684_800.0),
90
+ (4_102_444_800, None),
91
+ (1_700_000_000_000_000_000, None),
92
+ (12_345_678_901_234_567_890, None),
93
+ (True, None), (float("nan"), None), (10 ** 1000, None)):
94
+ with self.subTest(timestamp=value):
95
+ self.assertEqual(IncomingCall.from_event(self.event(timestamp=value)).timestamp, expected)
96
+ self.assertEqual(IncomingCall.from_event({}), IncomingCall(None, None, None, None))
97
+
98
+ def test_existing_message_notification_remains_unchanged(self):
99
+ received = []
100
+ self.router.message.add_handler(received.append)
101
+ event = {
102
+ "typeWebhook": "incomingMessageReceived",
103
+ "senderData": {"sender": "caller@c.us", "chatId": "caller@c.us"},
104
+ "messageData": {"typeMessage": "textMessage", "textMessageData": {"textMessage": "Hi"}},
105
+ }
106
+ self.router.route_event(event)
107
+ self.assertIs(type(received[0]), Notification)
108
+ self.assertEqual(received[0].message_text, "Hi")
109
+
110
+
111
+ if __name__ == "__main__":
112
+ unittest.main()
@@ -1,5 +1,6 @@
1
1
  from .bot import Bot, GreenAPI, GreenAPIBot, GreenAPIError, GreenAPIBotError
2
2
  from .manager.handler import Notification
3
+ from .manager.incoming_call import IncomingCall, IncomingCallNotification
3
4
  from .manager.state import BaseStates
4
5
 
5
6
  __all__ = [
@@ -9,5 +10,7 @@ __all__ = [
9
10
  "GreenAPIError",
10
11
  "GreenAPIBotError",
11
12
  "Notification",
13
+ "IncomingCall",
14
+ "IncomingCallNotification",
12
15
  "BaseStates"
13
16
  ]
@@ -0,0 +1,37 @@
1
+ """Call contracts import without installing the optional VoIP dependencies."""
2
+ import sys
3
+
4
+ if sys.version_info < (3, 11):
5
+ raise ImportError("whatsapp_chatbot_python.calls requires Python 3.11 or newer")
6
+
7
+ from .models import (
8
+ CallDirection,
9
+ CallEndReason,
10
+ CallEvent,
11
+ CallExecutionResult,
12
+ CallExecutor,
13
+ CallOptions,
14
+ CallSession,
15
+ CallServiceUnavailable,
16
+ CallState,
17
+ IncomingCallExecutor,
18
+ InputAudioOptions,
19
+ PlaybackOptions,
20
+ ProviderError,
21
+ RecordingOptions,
22
+ StateTransition,
23
+ TransitionCallback,
24
+ ACTIVE_STATES,
25
+ TERMINAL_STATES,
26
+ WAITING_STATES,
27
+ )
28
+
29
+ from .service import WhatsAppCallService
30
+ from .state_machine import CallStateMachine
31
+
32
+ __all__ = [
33
+ "ACTIVE_STATES", "TERMINAL_STATES", "WAITING_STATES", "CallDirection", "CallEndReason", "CallEvent",
34
+ "CallExecutionResult", "CallExecutor", "CallOptions", "CallSession", "CallServiceUnavailable", "CallState",
35
+ "CallStateMachine", "IncomingCallExecutor", "InputAudioOptions", "PlaybackOptions", "ProviderError",
36
+ "RecordingOptions", "StateTransition", "TransitionCallback", "WhatsAppCallService",
37
+ ]
@@ -0,0 +1,279 @@
1
+ """PCM/RTC boundary. Audio tracks depend on narrow source/sink interfaces."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from fractions import Fraction
6
+ from math import ceil, isfinite
7
+ from time import monotonic
8
+ from typing import Protocol
9
+
10
+ from aiortc import AudioStreamTrack
11
+ from aiortc.mediastreams import MediaStreamError
12
+ from av import AudioFrame, AudioResampler
13
+ from whatsapp_api_client_python.tools.voip.audio import CallAudio
14
+
15
+ from .models import EventSink, InputAudioOptions, MediaFailed, PcmChunk, PCM, PCM16_SAMPLE_BYTES
16
+ from .recorder import CallRecorder, Track
17
+
18
+
19
+ class AudioSource(Protocol):
20
+ def read_frame(self) -> bytes: ...
21
+ def replace_track(self) -> None: ...
22
+
23
+
24
+ class AudioSink(Protocol):
25
+ async def write_audio(self, data: bytes) -> None: ...
26
+
27
+
28
+ class RecordingTap:
29
+ """Align each track once, then advance by sample count, not arrival jitter."""
30
+ def __init__(self, recorder: CallRecorder) -> None:
31
+ self._recorder = recorder
32
+ self._epoch: float | None = None
33
+ self._positions: dict[Track, int] = {}
34
+ self._media_origins: dict[Track, tuple[float, int]] = {}
35
+
36
+ def activate(self) -> None:
37
+ if self._epoch is None:
38
+ self._epoch = monotonic()
39
+
40
+ def write(self, track: Track, data: bytes, media_time: float | None = None) -> None:
41
+ if self._epoch is None:
42
+ return
43
+
44
+ position = self._positions.setdefault(
45
+ track,
46
+ max(0, int((monotonic() - self._epoch) * PCM.rate))
47
+ )
48
+
49
+ if media_time is not None and isfinite(media_time):
50
+ origin_time, origin_position = self._media_origins.setdefault(
51
+ track,
52
+ (media_time, position),
53
+ )
54
+
55
+ position = max(position, origin_position + round((media_time - origin_time) * PCM.rate))
56
+
57
+ try:
58
+ self._recorder.write(track, PcmChunk(position, data))
59
+ except Exception as error:
60
+ self._recorder.disable(error)
61
+ return
62
+
63
+ self._positions[track] = position + len(data) // PCM16_SAMPLE_BYTES
64
+
65
+ def replace_tracks(self) -> None:
66
+ self._positions.clear()
67
+ self._media_origins.clear()
68
+
69
+
70
+ class BotTrack(AudioStreamTrack):
71
+ def __init__(self, source: AudioSource, tap: RecordingTap, emit: EventSink) -> None:
72
+ super().__init__()
73
+ self._source = source
74
+ self._tap = tap
75
+ self._emit = emit
76
+ self._position = 0
77
+ self._epoch = monotonic()
78
+
79
+ async def recv(self) -> AudioFrame:
80
+ if self.readyState != "live":
81
+ raise MediaStreamError
82
+
83
+ try:
84
+ return await self._next_frame()
85
+ except Exception as error:
86
+ self._emit(MediaFailed(error))
87
+ raise
88
+
89
+ async def _next_frame(self) -> AudioFrame:
90
+ due = self._epoch + self._position / PCM.rate
91
+
92
+ await asyncio.sleep(max(0, due - monotonic()))
93
+
94
+ data = self._source.read_frame()
95
+
96
+ if len(data) != PCM.bytes_per_frame:
97
+ raise ValueError("AudioSource must return exactly one PCM frame")
98
+
99
+ self._tap.write(Track.BOT, data)
100
+
101
+ frame = AudioFrame(
102
+ format=PCM.av_format,
103
+ layout=PCM.layout,
104
+ samples=PCM.samples_per_frame,
105
+ )
106
+
107
+ frame.planes[0].update(data)
108
+
109
+ frame.sample_rate = PCM.rate
110
+ frame.pts = self._position
111
+ frame.time_base = Fraction(1, PCM.rate)
112
+ self._position += PCM.samples_per_frame
113
+
114
+ return frame
115
+
116
+
117
+ class CallerSink:
118
+ def __init__(
119
+ self, sink: AudioSink, tap: RecordingTap, emit: EventSink,
120
+ options: InputAudioOptions | None = None,
121
+ ) -> None:
122
+ self._sink = sink
123
+ self._tap = tap
124
+ self._emit = emit
125
+ self._options = options or InputAudioOptions()
126
+ self._tasks: set[asyncio.Task] = set()
127
+ self._closing = False
128
+ self._failure_reported = False
129
+
130
+ async def attach(self, track) -> None:
131
+ if self._closing:
132
+ raise RuntimeError("Caller audio sink is closing")
133
+
134
+ task = asyncio.create_task(self._consume(track))
135
+ self._tasks.add(task)
136
+ task.add_done_callback(self._done)
137
+
138
+ async def _consume(self, track) -> None:
139
+ resampler = AudioResampler(format=PCM.av_format, layout=PCM.layout, rate=PCM.rate)
140
+ capacity = max(1, ceil(self._options.buffer_seconds / PCM.frame_seconds))
141
+ queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=capacity)
142
+ finished = asyncio.Event()
143
+ sender = asyncio.create_task(self._send(queue, finished), name="call:input-send")
144
+ reader = asyncio.current_task()
145
+ sender.add_done_callback(lambda done: self._sender_done(done, reader))
146
+ stream_ended = False
147
+
148
+ try:
149
+ while True:
150
+ for frame in resampler.resample(await track.recv()):
151
+ if sender.done():
152
+ return
153
+
154
+ size = frame.samples * PCM16_SAMPLE_BYTES
155
+ data = bytes(frame.planes[0])[:size]
156
+
157
+ media_time = (
158
+ float(frame.pts * frame.time_base)
159
+ if frame.pts is not None and frame.time_base is not None else None
160
+ )
161
+
162
+ self._tap.write(Track.CALLER, data, media_time)
163
+ self._enqueue(queue, data)
164
+ except MediaStreamError:
165
+ stream_ended = True
166
+ finally:
167
+ finished.set()
168
+
169
+ if not stream_ended:
170
+ sender.cancel()
171
+ await asyncio.gather(sender, return_exceptions=True)
172
+
173
+ def _sender_done(self, sender: asyncio.Task, reader: asyncio.Task) -> None:
174
+ if sender.cancelled() or self._closing:
175
+ return
176
+
177
+ error = sender.exception()
178
+
179
+ if error is not None:
180
+ self._report_failure(error)
181
+
182
+ if not reader.done():
183
+ reader.cancel()
184
+
185
+ def _report_failure(self, error: Exception) -> None:
186
+ if not self._failure_reported:
187
+ self._failure_reported = True
188
+ self._emit(MediaFailed(error))
189
+
190
+ @staticmethod
191
+ def _enqueue(queue: asyncio.Queue[bytes], data: bytes) -> None:
192
+ for start in range(0, len(data), PCM.bytes_per_frame):
193
+ try:
194
+ queue.put_nowait(data[start:start + PCM.bytes_per_frame])
195
+ except asyncio.QueueFull as error:
196
+ raise BufferError("Inbound audio buffer exceeded its time limit") from error
197
+
198
+ async def _send(self, queue: asyncio.Queue[bytes], finished: asyncio.Event) -> None:
199
+ while not finished.is_set() or not queue.empty():
200
+ try:
201
+ data = await asyncio.wait_for(queue.get(), PCM.frame_seconds)
202
+ except TimeoutError:
203
+ continue
204
+
205
+ await asyncio.wait_for(self._sink.write_audio(data), self._options.send_seconds)
206
+
207
+ def _done(self, task: asyncio.Task) -> None:
208
+ self._tasks.discard(task)
209
+
210
+ if task.cancelled():
211
+ return
212
+
213
+ error = task.exception()
214
+
215
+ if error is not None:
216
+ self._report_failure(error)
217
+
218
+ async def close(self) -> None:
219
+ self._closing = True
220
+ tasks = tuple(self._tasks)
221
+
222
+ for task in tasks:
223
+ task.cancel()
224
+
225
+ await asyncio.gather(*tasks, return_exceptions=True)
226
+
227
+
228
+ class AudioBridge:
229
+ def __init__(
230
+ self,
231
+ source: AudioSource,
232
+ sink: AudioSink,
233
+ recorder: CallRecorder,
234
+ emit: EventSink,
235
+ input_options: InputAudioOptions | None = None,
236
+ ) -> None:
237
+ self._source = source
238
+ self._sink = sink
239
+ self._tap = RecordingTap(recorder)
240
+ self._emit = emit
241
+ self._input_options = input_options
242
+ self._audio: CallAudio | None = None
243
+ self._closed = False
244
+
245
+ def activate_recording(self) -> None:
246
+ self._tap.activate()
247
+
248
+ async def create(self) -> CallAudio:
249
+ if self._closed:
250
+ raise RuntimeError("Audio bridge is closing")
251
+
252
+ await self._close_current()
253
+
254
+ if self._closed:
255
+ raise RuntimeError("Audio bridge is closing")
256
+
257
+ self._source.replace_track()
258
+ self._tap.replace_tracks()
259
+
260
+ local = BotTrack(self._source, self._tap, self._emit)
261
+ remote = CallerSink(self._sink, self._tap, self._emit, self._input_options)
262
+
263
+ async def close_pair() -> None:
264
+ local.stop()
265
+ await remote.close()
266
+
267
+ self._audio = CallAudio(local, remote.attach, close_pair)
268
+
269
+ return self._audio
270
+
271
+ async def close(self) -> None:
272
+ self._closed = True
273
+ await self._close_current()
274
+
275
+ async def _close_current(self) -> None:
276
+ audio, self._audio = self._audio, None
277
+
278
+ if audio is not None:
279
+ await audio.close()