Skip to content

Добавить возможность принимать входящие VoIP звонки в чатботе на Python - #51

Open
kennyromanov wants to merge 4 commits into
masterfrom
feature/incoming-calls
Open

kennyromanov wants to merge 4 commits into
masterfrom
feature/incoming-calls

Conversation

@kennyromanov

Copy link
Copy Markdown
Contributor

Требование:

  1. Добавить возможность принимать входящие звонки на бота (VoIP)
  2. Придумать удобный интерфейс взаимодействия с библиотекой
  3. Параметризировать: приветствия, языки, тайм-ауты

Алгоритм решения:

  1. Расширить FSM в библиотеке чатбота (VoIP)
  2. Добавить возможность реагировать на: входящий звонок, сброс звонка, тайм-аут дозвона
  3. Упаковать всё в удобный интерфейс для внешних разработчиков

Дополнительная информация:

 

Comment thread docs/README.md
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)

return value / 1_000

if 1_000_000 * MIN_SECONDS <= value < 1_000_000 * MAX_SECONDS:
return value / 1_000_000

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.

Приводим время в мc., нc. --> в UNIX (сек.)


await execution.voice.start()

execution.api = GreenAPI(self._id_instance, self._api_token_instance, host=self._api_url)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

эта requests.Session нигде не закрывается
_close_connections закрывает calls, voice и bridge_task, но не execution.api.session (а reject_incoming свой api.session.close() делает)
каждый звонок оставляет keep-alive-сокеты до GC - у долгоживущего бота это накопление открытых fd
закрывать в _close_connections / _finalize_call

self._active_runtime = None
self._active_loop = None
self._active_execution = None
path = execution.recorder.finish()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

finish() = join потока записи + синхронное кодирование всего звонка в MP3 через PyAV, а вызывается это из async _finalize_call - прямо в событийном цикле
на звонке в talk_timeout (120 с) кодирование занимает секунды CPU, и на это время замирают ВСЕ корутины: координатор очереди, notify_incoming_ended, следующий оффер
дёшево чинится: await asyncio.to_thread(execution.recorder.finish)


return

await execution.calls.openAsync(timeout=INITIAL_STATE_TIMEOUT_SECONDS)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

у входящего пути выше есть проверка state == RINGING, у исходящего зеркальной проверки DIALING нет
если приложение забыло fsm.apply(DEQUEUED) и сессия осталась в QUEUED: dialAsync всё равно выполняется (телефон абонента реально звонит), DIAL_ACCEPTED молча игнорируется FSM, ring_deadline проверяется только в RINGING → _next_timeout даёт None, _process_eventsждёт очередь вечно, executor никогда не возвращается

if execution.command_started:
WhatsAppCallService._apply_observed_incoming_answer(execution)

if state.state == "idle" and not execution.command_completed:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

путь _ANSWER_CONFIRMED никогда не выставляет command_completed
после такого ответа штатный "idle" (абонент положил трубку) попадает в эту ветку: завершение идёт через CALLER_LEFT вместо REMOTE_IDLE, session.remote_reason ("hangup") теряется, плюс лишний getState в _safe_finish_matching_incoming

self._record_cancellation(execution, error)
raise
except Exception:
if session.direction == CallDirection.INCOMING:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

в терминальное состояние переводятся только INCOMING-сессии
для OUTGOING упавший _prepare_call пробрасывает ошибку, а session навсегда остаётся в DIALING с end_reason=None
для входящих тот же отказ корректно даёт FAILED/setup_failed - контракт несимметричен

calls.on("disconnect", partial(self._on_disconnect, execution))

calls.on("error", lambda detail: runtime.events.put_nowait(
RuntimeEvent.bridge_failed(RuntimeError("callsRtc reported an error")),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

detail выбрасывается, все ошибки схлопываются в одинаковый RuntimeError, а _handle_call_failure логирует только type(error).__name__
в проде это лог «error=RuntimeError» и error_code="RuntimeError" - что именно сообщил сервер, узнать невозможно
вложить detail в сообщение исключения


if accepted_statuses is not None:
if isinstance(accepted_statuses, str):
accepted_statuses = [accepted_statuses]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

observer.py:132-146 уже разбирает str/list/tuple при регистрации - здесь та же нормализация повторяется на каждом событии
два места истины: при расширении форм фильтра (enum-статусы) их легко развести
достаточно один раз сохранить готовый список в filters

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants