Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -583,6 +588,38 @@ async def call_once():
asyncio.run(call_once())
```

### 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/)
Expand Down
50 changes: 43 additions & 7 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OS здесь не нужно - ломает внешние проекты (если задан системный ENV)

ring_timeout_seconds=30,
talk_timeout_seconds=120,
shutdown_timeout_seconds=10,
Expand All @@ -567,14 +572,45 @@ async def call_once():
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())
```

### Принять входящий звонок

Очередь, дедупликация предложений звонка и разбор уведомлений принадлежат приложению. Входящий звонок может использовать
того же работника очереди, что и исходящий. При получении `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/)
Expand Down
8 changes: 5 additions & 3 deletions examples/call.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from whatsapp_chatbot_python import GreenAPIBot, Notification
from whatsapp_chatbot_python import GreenAPIBot, IncomingCallNotification

bot = GreenAPIBot(
"1101000001", "d75b3a66374942c5b3c019c698abc2067e151558acbd412345"
)


@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()
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
47 changes: 46 additions & 1 deletion tests/calls/test_call_recording.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from array import array
from unittest.mock import patch
from queue import Full
from whatsapp_chatbot_python.calls.recording import CallRecorder, RATE
from whatsapp_chatbot_python.calls.recording import CallRecorder, MP3_BIT_RATE, RATE
import logging
import unittest
import av
Expand Down Expand Up @@ -40,6 +40,7 @@ def test_mixes_both_sides_and_keeps_pause_and_reconnect(self) -> None:

try:
with av.open(str(path)) as container:
self.assertEqual(container.streams.audio[0].bit_rate, MP3_BIT_RATE)
frames = [frame for packet in container.demux(audio=0) for frame in packet.decode()]

converted = av.AudioResampler(format="s16", layout="mono", rate=RATE)
Expand All @@ -61,6 +62,50 @@ def level(start, end):
finally:
path.unlink(missing_ok=True)

def test_frame_jitter_does_not_split_audio_but_media_gap_is_preserved(self) -> None:
clock = [0.0]
recorder = CallRecorder(logging.getLogger("recording-test"))
tracks = {}

def capture(_path):
for side in ("caller", "bot"):
stream = getattr(recorder, f"_{side}")
stream.seek(0)
tracks[side] = array("h")
tracks[side].frombytes(stream.read())

with patch("whatsapp_chatbot_python.calls.recording.monotonic", side_effect=lambda: clock[0]), \
patch.object(recorder, "_encode", side_effect=capture):
recorder.set_generation(1)
recorder.start()
recorder.add_bot(pcm(3000, 480), 1)
clock[0] = 0.045 # A delayed callback must not insert 25 ms of silence.
recorder.add_bot(pcm(4000, 480), 1)

clock[0] = 0.02
recorder.add_caller(pcm(1000, 480), 1, media_time=10.0)
clock[0] = 0.065
recorder.add_caller(pcm(2000, 480), 1, media_time=10.02)
clock[0] = 0.075
recorder.add_caller(pcm(3000, 480), 1, media_time=10.04)
clock[0] = 0.15
recorder.add_caller(pcm(4000, 480), 1, media_time=10.10)

recorder.set_generation(2)
clock[0] = 0.22
recorder.add_caller(pcm(5000, 480), 2, media_time=0.0)
path = recorder.finish()

self.assertIsNotNone(path)
try:
self.assertEqual(tracks["bot"], array("h", [3000] * 480 + [4000] * 480))
self.assertEqual(tracks["caller"][:1440], array("h", [1000] * 480 + [2000] * 480 + [3000] * 480))
self.assertEqual(tracks["caller"][1440:2400], array("h", [0] * 960))
self.assertEqual(tracks["caller"][2400:2880], array("h", [4000] * 480))
self.assertEqual(tracks["caller"][4800:5280], array("h", [5000] * 480))
finally:
path.unlink(missing_ok=True)

def test_no_audio_or_queue_overflow_disables_recording(self) -> None:
recorder = CallRecorder(logging.getLogger("recording-test"))

Expand Down
8 changes: 8 additions & 0 deletions tests/calls/test_call_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from whatsapp_chatbot_python.calls.runtime import CallRuntime, RuntimeEvent, RuntimeEventType
import asyncio
import unittest
from time import monotonic


class CallRuntimeTest(unittest.IsolatedAsyncioTestCase):
Expand All @@ -25,6 +26,13 @@ async def test_empty_queue_returns_none_when_timer_expires(self) -> None:
runtime.start_ringing()
self.assertIsNone(await runtime.next_event(CallState.RINGING))

async def test_incoming_deadline_is_absolute(self) -> None:
runtime = CallRuntime(30, 1, 1)
deadline = monotonic() + 0.01
runtime.start_ringing(deadline=deadline)
self.assertEqual(runtime.ring_deadline, deadline)
self.assertIsNone(await runtime.next_event(CallState.RINGING))

async def test_cancellation_is_not_swallowed_when_event_becomes_ready(self) -> None:
waiting = asyncio.Event()

Expand Down
Loading
Loading