Skip to content
Merged
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
64 changes: 53 additions & 11 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 All @@ -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/)
Expand Down
64 changes: 53 additions & 11 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,
Comment thread
kennyromanov marked this conversation as resolved.
ring_timeout_seconds=30,
talk_timeout_seconds=120,
shutdown_timeout_seconds=10,
Expand All @@ -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/)
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()
3 changes: 3 additions & 0 deletions ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
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
3 changes: 2 additions & 1 deletion tests/calls/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
162 changes: 162 additions & 0 deletions tests/calls/fakes.py
Original file line number Diff line number Diff line change
@@ -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)))
Loading
Loading