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.
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/PKG-INFO +54 -12
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/README.md +53 -11
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/setup.py +1 -1
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/tests/test_calls_imports.py +7 -5
- whatsapp_chatbot_python-1.1.0/tests/test_incoming_call_notifications.py +112 -0
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python/__init__.py +3 -0
- whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/__init__.py +37 -0
- whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/audio.py +279 -0
- whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/call_runner.py +750 -0
- whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/greenapi.py +294 -0
- whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/models.py +483 -0
- whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/openai.py +297 -0
- whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/playback_state_machine.py +257 -0
- whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/recorder.py +320 -0
- whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/service.py +180 -0
- whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/calls/state_machine.py +183 -0
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python/manager/handler.py +12 -13
- whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/manager/incoming_call.py +119 -0
- whatsapp_chatbot_python-1.1.0/whatsapp_chatbot_python/manager/observer.py +188 -0
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python/manager/router.py +12 -8
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python.egg-info/PKG-INFO +54 -12
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python.egg-info/SOURCES.txt +7 -4
- whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/__init__.py +0 -34
- whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/contracts.py +0 -28
- whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/models.py +0 -121
- whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/playback_state_machine.py +0 -46
- whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/realtime_voice.py +0 -520
- whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/recording.py +0 -151
- whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/runtime.py +0 -136
- whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/service.py +0 -439
- whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/calls/state_machine.py +0 -194
- whatsapp_chatbot_python-1.0.0/whatsapp_chatbot_python/manager/observer.py +0 -122
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/LICENSE +0 -0
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/setup.cfg +0 -0
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/tests/test_manager.py +0 -0
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python/bot.py +0 -0
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python/filters.py +0 -0
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python/manager/__init__.py +0 -0
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python/manager/state.py +0 -0
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python.egg-info/dependency_links.txt +0 -0
- {whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python.egg-info/requires.txt +0 -0
- {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.
|
|
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=
|
|
606
|
-
api_token_instance=
|
|
607
|
-
openai_api_key=
|
|
608
|
-
realtime_model=
|
|
609
|
-
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
|
-
|
|
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
|
|
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=
|
|
554
|
-
api_token_instance=
|
|
555
|
-
openai_api_key=
|
|
556
|
-
realtime_model=
|
|
557
|
-
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
|
-
|
|
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
|
|
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/)
|
|
@@ -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
|
|
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 "
|
|
37
|
-
assert
|
|
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()
|
{whatsapp_chatbot_python-1.0.0 → whatsapp_chatbot_python-1.1.0}/whatsapp_chatbot_python/__init__.py
RENAMED
|
@@ -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()
|