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
23 changes: 18 additions & 5 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,23 @@ jobs:
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with ruff
run: |
# stop the build if there are Python syntax errors or undefined names
ruff check --output-format=github --select=E9,F63,F7,F82 --target-version=py37 .
# default set of ruff rules with GitHub Annotations
ruff check --output-format=github --target-version=py37 .
ruff check --config ruff.toml --output-format=github .
- name: Test with pytest
run: |
pytest
pytest tests --ignore=tests/calls

calls:
name: ${{ matrix.python-version }} (VoIP)
runs-on: ubuntu-24.04
strategy:
matrix:
python-version: [ "3.11", "3.12", "3.13" ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install optional call dependencies
run: python -m pip install '.[voip]' pytest
- name: Test calls and base imports
run: python -m pytest tests
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,4 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.idea/
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,72 @@ def show_interactive_buttons_reply_handler(notification: Notification) -> None:
bot.run_forever()
```

## Optional voice calls (VoIP)

The `whatsapp_chatbot_python.calls` package executes one outgoing WhatsApp
voice call through the GreenAPI SDK, with an OpenAI Realtime conversation,
timeouts, audio playback and a best-effort MP3 recording. It requires **Python
3.11+**, an authorized VoIP-capable instance and an OpenAI API key. Install the
optional dependencies (or use `'.[voip]'` when installing from this checkout):

```shell
python -m pip install 'whatsapp-chatbot-python[voip]'
```

The extra pins the SDK and audio/provider versions used. The ordinary chatbot import does not load OpenAI, aiortc or
PyAV. Call models, contracts and the FSM are available from `whatsapp_chatbot_python.calls`; import the executor
explicitly from `whatsapp_chatbot_python.calls.service`.

### Execute one call

The model and voice are supplied by the application. This example performs an actual call when run with valid
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

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"],
ring_timeout_seconds=30,
talk_timeout_seconds=120,
shutdown_timeout_seconds=10,
logger=logging.getLogger("calls"),
)

async def call_once():
session = CallSession(
sender_id="79123456789@c.us",
chat_id="79123456789@c.us",
language="ru",
)
fsm = CallStateMachine()
fsm.apply(session, CallEvent.ENQUEUED)
fsm.apply(session, CallEvent.DEQUEUED)
result = await service.execute(session, fsm.apply)
try:
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())
```

## Service methods documentation

[Service methods documentation](https://green-api.com/en/docs/api/)
Expand Down
62 changes: 62 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,68 @@ def show_interactive_buttons_reply_handler(notification: Notification) -> None:
bot.run_forever()
```

## Голосовые звонки (необязательное дополнение VoIP)

Пакет `whatsapp_chatbot_python.calls` выполняет один исходящий WhatsApp-звонок: подключение через GreenAPI SDK,
голосовой разговор с OpenAI Realtime, тайм-ауты, воспроизведение и запись MP3. Требуются **Python 3.11+**,
авторизованный инстанс с поддержкой VoIP и ключ OpenAI.

```shell
python -m pip install 'whatsapp-chatbot-python[voip]'
```

Для установки из исходников используйте `python -m pip install -e '.[voip]'`. Дополнение фиксирует версии SDK, OpenAI и
аудиобиблиотек. Обычный импорт чат-бота не загружает OpenAI, aiortc или PyAV.

### Выполнить один звонок

Модель и голос задаёт приложение. При запуске с действительными реквизитами этот пример совершит настоящий звонок:

```python
import asyncio
import logging
import os

from whatsapp_chatbot_python.calls import (
CallEvent, CallSession, CallStateMachine,
)
from whatsapp_chatbot_python.calls.service import WhatsAppCallService

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"],
ring_timeout_seconds=30,
talk_timeout_seconds=120,
shutdown_timeout_seconds=10,
logger=logging.getLogger("calls"),
)

async def call_once():
session = CallSession(
sender_id="79123456789@c.us",
chat_id="79123456789@c.us",
language="ru",
)
fsm = CallStateMachine()
fsm.apply(session, CallEvent.ENQUEUED)
fsm.apply(session, CallEvent.DEQUEUED)
result = await service.execute(session, fsm.apply)
try:
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())
```

## Документация по методам сервиса

[Документация по методам сервиса](https://green-api.com/docs/api/)
Expand Down
6 changes: 6 additions & 0 deletions ruff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Match the SDK's lint policy: catch code errors without rewriting legacy style.
target-version = "py39"
force-exclude = true

[lint]
select = ["E4", "E7", "E9", "F"]
11 changes: 10 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
author="GREEN API",
author_email="support@green-api.com",
url="https://github.com/green-api/whatsapp-chatbot-python",
packages=find_packages(exclude=["tests"]),
packages=find_packages(include=["whatsapp_chatbot_python", "whatsapp_chatbot_python.*"]),
classifiers=[
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
Expand Down Expand Up @@ -44,5 +44,14 @@
" (CC BY-ND 4.0)"
),
install_requires=["whatsapp-api-client-python>=0.0.53"],
extras_require={
"voip": [
"whatsapp-api-client-python[voip]==0.0.55; python_version >= '3.11'",
"openai==3.20.0; python_version >= '3.11'",
"aiortc==1.15.0; python_version >= '3.11'",
"av==17.1.0; python_version >= '3.11'",
"websockets>=16,<17; python_version >= '3.11'",
],
Comment thread
kennyromanov marked this conversation as resolved.
},
python_requires=">=3.7"
Comment thread
kennyromanov marked this conversation as resolved.
)
Empty file added tests/calls/__init__.py
Empty file.
12 changes: 12 additions & 0 deletions tests/calls/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Keep base installations testable without installing the optional audio stack."""

import sys
from importlib.util import find_spec

collect_ignore_glob = []
if sys.version_info < (3, 11):
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"
]
Loading
Loading