Робот, который звонит в WhatsApp: как я автоматизировал то, что автоматизировать не предполагалось

от автора

Задача формулировалась в одно предложение: робот должен сам звонить людям в WhatsApp, проигрывать голосовое приветствие, записывать ответ и присылать оператору расшифровку с вердиктом «да / нет / надо посмотреть руками».

Проблема в том, что официально это невозможно. У WhatsApp нет API для голосовых роботов: Business API умеет шаблонные сообщения, провайдеры на рынке умеют текстовые рассылки, голосовые звонки закрыты наглухо. Готового решения не существует ни за деньги, ни за большие деньги.

Зато существует официальное десктопное приложение, которое звонить умеет. И его можно водить за руку.

Это рассказ о том, что из этого вышло: с чего начиналось, какие подходы умерли по дороге, четыре слоя автоматизации — от CDP до виртуальных аудиокабелей — и один урок про производительность, который перечеркнул половину архитектуры уже на живых звонках.

Сразу оговорюсь: по отношению к правилам платформы это серая зона, и я не предлагаю это повторять. Мне здесь интересна чистая инженерия — как автоматизируется то, что автоматизировать не предполагалось. Ограничения и риски честно разберу в конце.

Кода будет много. Всё интересное в этом проекте живёт именно в деталях.


Часть I. С чего всё начиналось

Первый подход: обычный SIP

Изначально никакого WhatsApp не было. Была задача квалифицировать лидов из базы: позвонить, проиграть короткое предложение, спросить «интересно — нажмите 1». Классический автообзвон, который решается SIP-телефонией.

Провайдер за HTTP, интерфейс на полторы страницы:

export class SipVoiceProvider implements VoiceCallProvider {  readonly name = 'sip' as const;  async call(req: VoiceCallRequest): Promise<VoiceCallResult> {    const res = await fetch(`${this.cfg.baseUrl.replace(/\/$/, '')}/calls`, {      method: 'POST',      headers: {        'content-type': 'application/json',        authorization: `Bearer ${this.cfg.token}`,      },      body: JSON.stringify({        phone: req.phone,        audio_url: req.audioPath,        callback_id: String(req.leadId),      }),    });    // …  }}

Всё работало. И упёрлось не в технику, а в людей.

Значительная часть аудитории просто не берёт трубку с незнакомых номеров. Не «не заинтересовалась предложением», а не подняла трубку вообще: телефон звонит, номер неизвестный, человек сбрасывает. Конверсия упирается в потолок, который никаким скриптом не пробить.

При этом те же самые люди отвечают на звонок в мессенджере. Там нет обезличенного номера — там аватар, имя, знакомый интерфейс. Психологически это другой звонок.

Второй подход: каскад

Отсюда родилась архитектура, которая и осталась в проекте:

CSV/Excel  →  SIP-звонок  →  WhatsApp Desktop-звонок  →  STT + классификация  →  карточка в Telegram               (DTMF 1/2)      (greeting WAV → ответ)      (whisper medium)       (лид + транскрипт)

Сначала обычный звонок. Не взяли трубку — тем же людям уходит звонок через WhatsApp. Это не замена канала, а добор охвата сверху: мы дотягиваемся до тех, кого первый канал теряет, не каннибализируя его.

Был ещё третий, текстовый канал — wa_text, follow-up сообщением после неответа. Его в итоге вырезали: он размывал фокус и статистику, а рассылками в мессенджере занимаются и без нас. Режим остался чисто голосовым.

Оставалось выяснить, как вообще заставить приложение звонить.

Разведка: что это за зверь

Первые недели проекта — это не код, а вскрытие. В репозитории до сих пор лежат артефакты того периода: inspect_wa.py, probe_call_window.py, probe-call-button.ts, wa-desktop-inspect-header.ts, wa-desktop-inspect-menu.ts. Скрипты, которые ничего не автоматизируют — они просто ходят по дереву интерфейса и печатают, что видят.

Главное открытие этого этапа определило всю дальнейшую архитектуру.

Современный WhatsApp Desktop (Beta) под Windows — это не Electron и не нативное приложение целиком. Это WinUI3-оболочка, внутри которой живёт WebView2, а в нём крутится самый обычный web.whatsapp.com.

То есть внутри приложения — браузер. А браузер можно отлаживать.

Второе открытие оказалось неприятным: окно активного звонка — уже не веб. Это отдельное нативное WinUI3-окно, которого в DOM нет вообще.

Так и определились два слоя автоматизации: веб-часть через Playwright и нативная часть через Windows UI Automation. А потом выяснилось, что нужен ещё и третий слой — звук.


Часть II. Слой первый: WebView2 и CDP

WebView2 читает дополнительные аргументы запуска из переменной окружения:

WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222 --remote-allow-origins=*

После этого подключение — одна строка:

const browser = await chromium.connectOverCDP('http://127.0.0.1:9222');

Грабля: порт открыт не только у WhatsApp

Эта переменная окружения глобальная для пользователя. Она открывает отладочный порт не для WhatsApp, а для каждого WebView2-приложения в системе. На 9222 может ответить что угодно — виджет, установщик, любая программа на этом стеке. Взять первую страницу первого контекста нельзя:

/** * Find the WhatsApp page across all of a browser's contexts. The global * WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS env var opens the same debug port * for EVERY WebView2 app, so a non-WhatsApp app could be the one answering * on this port — we only accept a browser that actually exposes a * web.whatsapp.com page (stable or beta, consumer or business). */private static findWhatsAppPage(browser: Browser): { ctx: BrowserContext; page: Page } | null {  for (const ctx of browser.contexts()) {    const page = ctx.pages().find((p) => p.url().includes('web.whatsapp.com'));    if (page) return { ctx, page };  }  return null;}

Принимаем браузер, только если он реально отдаёт страницу WhatsApp. И поддерживаем список портов через запятую — стабильная версия и бета мапятся на разные.

Грабля: окно, которое не хочет кликаться

Кнопка голосового звонка живёт в DOM, значит кликается Playwright’ом. Так я думал.

На практике клик срабатывал, только когда окно WhatsApp было на переднем плане. Стоило увести фокус — вызов молча проваливался: в логах page.click failed, falling back to clickByRect, флаг everSawWindow:false, окно звонка не появлялось.

Причина — оптимизация Chromium. WebView2 определяет, что окно перекрыто, и троттлит рендеринг: перестаёт обновлять layout, замораживает таймеры, отправляет вкладку в фон. Playwright, который перед кликом проверяет видимость и стабильность элемента, в такой ситуации бессилен.

Лечится не кодом, а флагами запуска:

--disable-features=CalculateNativeWinOcclusion,CalculateNativeWinOcclusionTracking--disable-backgrounding-occluded-windows--disable-renderer-backgrounding--disable-background-timer-throttling

Отдельное удовольствие — применить их. Переменная пользовательская, поэтому: закрыть WhatsApp, перезапустить explorer.exe (чтобы обновилось окружение), заново запустить приложение.

После этого клики по неактивному окну проходят. Но не по свёрнутому: у минимизированного окна размеры 0×0, координат для клика физически нет. Отсюда единственное операционное правило, которое пришлось объяснять оператору: окно можно перекрывать чем угодно, но нельзя сворачивать.

Грабля: React не верит синтетическим кликам

Обычный page.click() упирается в проверки видимости, а React-обработчики WhatsApp не реагируют на голый dispatchEvent(new MouseEvent('click')) — им нужна полная последовательность указательных событий:

(sel) => {  const btn = document.querySelector(sel);  if (!btn) throw new Error('forceClick: not found: ' + sel);  const r = btn.getBoundingClientRect();  const x = r.left + r.width / 2;  const y = r.top + r.height / 2;  const opts = { bubbles: true, cancelable: true, composed: true, view: window,                 clientX: x, clientY: y, button: 0, buttons: 1 };  btn.dispatchEvent(new PointerEvent('pointerdown', opts));  btn.dispatchEvent(new MouseEvent('mousedown', opts));  btn.dispatchEvent(new PointerEvent('pointerup', opts));  btn.dispatchEvent(new MouseEvent('mouseup', opts));  btn.dispatchEvent(new MouseEvent('click', opts));}

Обратите внимание: это хранится строкой исходника, а не функцией. Причина неочевидная: сборщик (esbuild/tsx) оборачивает именованные функции хелпером name, которого не существует в контексте страницы. Передаёте функцию в page.evaluate() — получаете name is not defined внутри WhatsApp. Строка компилируется страницей как есть.

Грабля: синхронизация, которая рестартует сама себя

Открытие чата — навигация по deep-link с ожиданием заголовка. Первая версия делала три попытки по 20 секунд. Логично же: не дождались — перезагрузим.

Оказалось наоборот:

// When the linked account is under heavy parallel call load, each deep-link// navigation reloads WA Web into its "downloading messages" sync screen, which// can take longer to settle. Re-navigating RESTARTS that sync from scratch, so// too many short attempts prevent it from ever completing. We therefore give a// single long header wait and only one safety re-navigation.const CHAT_OPEN_TIMEOUT_MS = 60_000;const CHAT_OPEN_ATTEMPTS = 2;

Под нагрузкой каждая навигация роняет WA Web в экран синхронизации сообщений, а повторная навигация запускает эту синхронизацию заново. Три коротких попытки гарантировали, что она не закончится никогда. Одно длинное ожидание вместо трёх коротких — и проблема исчезла.

Ещё одна: одна страница на всех

Страница WebView2 одна, а работать с ней хотят все: набор номера, отправка текста, опрос непрочитанных. Запущенные параллельно, они закрывают друг другу чаты прямо посреди операции.

Решение — сериализация всех составных операций через промис-цепочку:

async runExclusive<T>(fn: () => Promise<T>): Promise<T> {  const next = this.chain.then(fn, fn);  // keep the chain alive even if `fn` throws  this.chain = next.catch(() => undefined);  return next;}

Тонкость в then(fn, fn) и последующем .catch(): упавшая операция не должна рвать цепочку — следующие обязаны выполниться.


Часть III. Слой второй: нативное окно и UI Automation

Клик проходит, звонок уходит. И тут WebView2 заканчивается: окно активного звонка — нативное, Playwright о нём не знает.

Второй слой — Python-сайдкар на pywinauto поверх UI Automation. Он умеет три вещи: найти окно звонка, понять, что трубку подняли, и положить её.

Как понять, что человек ответил

Ни события, ни колбэка, ни статуса. Есть только дерево UIA. Первая версия кэшировала контрол CallStatusText и опрашивала его.

Не работает:

# Re-find the window and re-walk its text controls FRESH every tick. A# cached UIA element handle goes stale when WhatsApp rebuilds the call# window on pickup — it then returns a frozen "Calling..." forever, so the# answer is never seen. find_call_window is cheap (~0.17s), and the call# window's subtree is tiny, so a fresh walk per tick is fast enough.

При поднятии трубки WhatsApp пересобирает окно звонка. Закэшированный хэндл протухает и до конца времён возвращает замороженное «Calling…». Робот считал, что ему не ответили, пока человек говорил в трубку.

Решение: каждый тик заново искать окно и обходить все текстовые контролы — не один известный, а всё поддерево. Поиск окна стоит ~0.17 с, поддерево крошечное, 0.3 с на тик хватает.

Дальше — два независимых сигнала. Первый: таймер разговора M:SS. Второй пришлось добавить отдельно:

# Connected indicators: on WhatsApp Beta the call window stops showing a# reliable M:SS timer once the recipient picks up — instead the in-call# controls appear, whose labels ("Muted"/"Mute"/"Unmute" and RU equivalents)# are present ONLY after pickup (verified: absent during a ringing-only call,# present on an answered one). Used as a pickup signal alongside the timer.CONNECTED_RE = re.compile(    r"(Mute|Unmute|Muted|Speaker|Без звука|Выкл.*микрофон|Вкл.*микрофон|Динамик)",    re.IGNORECASE,)

На бете таймер после ответа может исчезнуть вовсе, зато появляются кнопки управления микрофоном, которых в звонящем состоянии нет.

Самая красивая ошибка проекта

WhatsApp коротко показывает 0:00, пока оператор связи ещё маршрутизирует вызов — до того, как у абонента реально пошёл гудок.

Робот принимал это за ответ. Начинал играть приветствие в пустоту и записывать гудки. А дальше faster-whisper покорно галлюцинировал из гудков «Алло» и «а а а» — и классификатор считал звонок состоявшимся, а лид живым.

Лечится составным условием: нулевому таймеру верим только если до этого видели состояние «звонит».

# A timer in CallStatusText means connected. Once the recipient# has been rung (ringing_seen), even "00:00" is a real pickup —# the transient "0:00" that warranted a >=1s guard only appears# during the earlier "Calling..." routing phase, before ringing.if secs is not None and (secs >= PICKUP_MIN_TIMER_SEC or                         (secs >= 0 and ringing_seen)):    payload = {"result": "answered", "lastStatus": txt,               "timerSec": secs, "timerSource": aid}

Итоговый автомат различает четыре состояния: answered, no_answer (окно висело до таймаута), ended (появилось и исчезло — сбросили) и no_window (не появилось вовсе). В no_answer кладётся полная карта увиденных текстов — чтобы потом по сохранённой попытке восстановить, как именно выглядело окно.

Как положить трубку

Казалось бы, закрыть окно — что тут сложного.

Оказалось, этот билд WinUI3 игнорирует WM_CLOSE. Первая версия работала по принципу «выстрелил и забыл»: искала кнопку по точному NewEndCallButton, при неудаче слала один WM_CLOSE и рапортовала об успехе, ничего не проверяя. Итог — окно оставалось висеть, и следующий звонок коллидил с предыдущим.

Рабочая версия — эскалация с проверкой:

# End-call button identifiers. The primary control is the WinUI3# NewEndCallButton, but on some window states (still-ringing / re-laid-out# window) the automation_id or accessible name differs, so we also accept a# set of known ids and a name regex covering EN + RU labels. Matching the# REAL end-call button — rather than falling straight to WM_CLOSE — is what# actually ends the call on this build; WM_CLOSE alone often does not.END_CALL_AUTO_IDS = ("NewEndCallButton", "EndCallButton", "HangUpButton", "EndCall")END_CALL_NAME_RE = re.compile(    r"(End call|Hang ?up|Decline|Leave call|Завершить|Отклонить|Заверш|Отбой)",    re.IGNORECASE,)_WM_CLOSE = 0x0010_WM_SYSCOMMAND = 0x0112_SC_CLOSE = 0xF060

Порядок такой: ищем настоящую кнопку завершения — по нескольким возможным automation_id и по доступному имени на двух языках, потому что в разных состояниях окна идентификатор отличается. Жмём её через UIA Invoke, затем мышью. Не помогло — постим в топ-левел HWND и SC_CLOSE (пункт «Закрыть» системного меню), и WM_CLOSE: часть состояний WinUI3 глотает голый WM_CLOSE, но честно отрабатывает системную команду.

И главное — после этого опрашиваем, исчезло ли окно на самом деле, до трёх раундов, и возвращаем честный статус closed / not_closed / no_window. Именно клик по настоящей кнопке, а не отправка сообщения окну, реально завершает звонок на этом билде.


Часть IV. Слой третий: звук, которого нет в API

Звонок идёт, трубку подняли. Теперь надо подать в разговор заранее записанное приветствие и снять то, что говорит собеседник.

У приложения, разумеется, нет никакого «проиграйте вот этот WAV в звонок». Для WhatsApp существуют только микрофон и динамики. Значит, микрофон и динамики надо подделать.

Схема на двух виртуальных кабелях

greeting.wav  ─Python sounddevice─►  Hi-Fi Cable Input  ─loop─►  Hi-Fi Cable Output  ─►  WhatsApp (микрофон)                                                                                              ↓                                                                                     WhatsApp (динамики)                                                                                              ↓речь абонента  ◄─STT listener─  CABLE Output  ◄─loop─  CABLE Input  ◄──────────────────────────┘

Два независимых кабеля VB-Audio, каждый со своей парой вход/выход. В один мы играем приветствие, и WhatsApp видит его как микрофон. Из второго снимаем то, что WhatsApp выводит «в динамики».

Звучит просто. Дальше начинается Windows.

Грабля: как заставить одно приложение слушать нужное устройство

Нельзя просто переключить системное устройство по умолчанию — тогда весь звук машины уедет в кабель, и оператор перестанет слышать что-либо вообще. Нужен per-app routing: конкретно у WhatsApp микрофон и динамики свои, у всей остальной системы — обычные.

Windows это умеет, но с фокусом:

# Watch for a WA Desktop active audio session, then pin per-app routing# the moment one appears. SVV /SetAppDefault only takes effect when the# target app has an active audio session (Windows Audio Policy registers# apps lazily). The pin then persists in registry across future calls.

Windows Audio Policy регистрирует приложение лениво — оно появляется в системе как аудиоклиент только в момент, когда реально начинает играть или писать звук. То есть привязать устройства к WhatsApp «заранее» невозможно: до первого звонка приложения для аудиоподсистемы не существует.

Отсюда скрипт-наблюдатель: он крутится в цикле, дампит текущие аудиосессии в JSON и ждёт, когда среди них появится WhatsApp. Оператору в этот момент говорят нажать «позвонить» на тестовом контакте:

while ((Get-Date) -lt $deadline) {  & $svv /sjson $tmp  Start-Sleep -Milliseconds 200  $rows = Get-Content $tmp -Raw | ConvertFrom-Json  $waSessions = $rows | Where-Object {    $_.Type -eq "Application" -and $_.Name -like "*$ProcessHint*"  }  if ($waSessions) {    $pid_ = ($waSessions | Select-Object -First 1)."Process ID"    & $svv /SetAppDefault $capId "Capture" $pid_    & $svv /SetAppDefault $renId "Render"  $pid_    $pinned = $true    break  }  Start-Sleep -Milliseconds 300}

Поймали сессию — прибили устройства к её PID. После этого привязка сохраняется в реестре и действует для всех будущих звонков.

Отдельный аттракцион — где именно Windows это хранит:

$base = "HKCU:\Software\Microsoft\Internet Explorer\LowRegistry\Audio\PolicyConfig\PropertyStore"

Да, пер-аппликационные аудионастройки современной Windows живут в ветке реестра Internet Explorer. Скрипт после привязки читает эту ветку и проверяет, что записи про WhatsApp действительно появились — иначе «команда прошла, но ничего не применилось» обнаружилось бы только на живом звонке.

Грабля: одно устройство, четыре разных устройства

В Windows одно физическое устройство видно на нескольких host API одновременно, и они не равноценны:

# When several devices match, prefer WASAPI > WDM-KS > DirectSound > MME# (lower latency, and MME picks up a 44.1 kHz path even when the device's# native mix is 48 kHz).api_priority_by_name = {"wasapi": 0, "wdm-ks": 1, "directsound": 2, "mme": 3}

Пустите звук через MME — система молча возьмёт путь на 44.1 кГц там, где родной формат устройства 48 кГц, добавит пересэмплирование и задержку. Пришлось делать приоритет host API и возможность форсировать конкретный через параметр, потому что на хостах с несколькими кабелями частоты дискретизации оказываются асимметричными, и универсального правила нет.

Само приветствие в итоге играется через MME, 8 кГц моно, blocksize 4096 фреймов — телефонная полоса, крупный буфер, максимальная устойчивость к рывкам. Красивой теории тут нет, есть подобранная эмпирика.

Грабля: WebRTC съедает начала слов

Это моя любимая находка во всём проекте.

Приветствие проигрывалось целиком, но абоненты жаловались, что слышат обрывки. Причина — VAD в WebRTC-стеке WhatsApp. Он считает, что тишина в микрофоне = человек молчит, и перестаёт передавать поток. Когда речь возобновляется, детектор просыпается не мгновенно и срезает первые миллисекунды — то есть края слов после каждой паузы в записи.

Приложение работает штатно, оно защищает канал от передачи тишины. Просто наша «тишина» — это паузы внутри осмысленной фразы.

Решение: непрерывный розовый шум на −45 dBFS в тот же кабель, который несёт приветствие. Для VAD канал никогда не молчит, гейт не срабатывает, слова доходят целиком. Для абонента −45 dBFS — это чуть заметный фон, который в телефонном звонке звучит абсолютно естественно.

/** * Long-running pink-noise sidecar. Plays a continuous low-level signal * into the same cable that carries the greeting so WhatsApp's WebRTC * voice-activity detector never gates transmission during silent gaps * in our pre-recorded WAV. */export class NoiseFloorSidecar {

Останавливается сайдкар закрытием stdin — скрипт следит за ним и выходит сам, без сигналов и убийства процесса:

async stop(): Promise<void> {  // Close stdin -> the python script watches stdin and exits cleanly  try { proc.stdin?.end(); } catch { /* ignore */ }  // …с таймаутом на SIGTERM, если не вышел за секунду}

Грабля, стоившая больше всего времени и не связанная с кодом

Машина администрировалась удалённо, сначала — через RDP.

Под RDP виртуальные кабели VB-Audio не перечисляются. Они видны только через WDM-KS, а RDP-сессия подменяет аудиоподсистему целиком и этих устройств не показывает. Скрипт честно сообщал, что устройства нет. В системе оно при этом было — просто не в той сессии, из которой мы смотрели.

Решение оказалось организационным: удалённый доступ переехал на RustDesk, который работает поверх реальной консольной сессии и аудиостек не трогает.

Мораль: если автоматизация работает руками и не работает удалённо — подозревайте не код, а сессию.


Часть V. Слой четвёртый: расшифровка

Записанный WAV надо превратить в текст. Здесь faster-whisper, локально, без облаков — записи разговоров реальных людей за пределы машины не уходят, и это принципиальный пункт, а не экономия.

Как не надо

Первая версия делала очевидное: на каждый звонок спавнила процесс, который грузил модель, транскрибировал и умирал.

На модели small это терпимо. На medium, которая заметно точнее на русском, — катастрофа: загрузка модели на каждый звонок под конкуренцией за CPU раздувалась до больше 120 секунд, сайдкар отваливался по таймауту, а линия всё это время оставалась занятой. Робот держал живого человека на линии, пока в фоне грузились веса.

Тёплый сервер модели

Модель поднимается один раз и живёт, общаясь с Node построчным JSON по stdin/stdout:

Protocol (one JSON object per line, newline-terminated):  Request  (stdin):    {"id": 7, "wav": "…/rec.wav", "language": "ru", "vad": false}  Response (stdout, exactly one line per request):    {"result":"ok","id":7,"text":"…","segments":[…],     "filtered":[…],"language":"ru","language_probability":0.98,     "duration":12.0,"inferenceSec":3.9}    {"result":"error","id":7,"error":"<message>"}

Два решения, которые сильно упростили эксплуатацию.

Первое: ошибка возвращается на конкретный запрос, а не роняет процесс. Один битый WAV не должен убивать загруженную модель:

for line in sys.stdin:    try:        req = json.loads(line)    except json.JSONDecodeError as e:        emit({"result": "error", "error": f"bad request json: {e}"})        continue    try:        emit(transcribe_one(model, req, args.language))    except Exception as e:  # never let one bad WAV kill the loop        emit({"result": "error", "id": req.get("id"), "error": repr(e)})

Второе: маркер готовности печатается в stderr, чтобы stdout остался чистым потоком результатов. Мелочь, которая экономит часы отладки протокола.

Whisper, который смотрел слишком много YouTube

Отдельного упоминания заслуживает фильтр галлюцинаций. Whisper на русском, получив тишину или шум, уверенно выдаёт титры из роликов:

HALLUCINATION_RE = re.compile("|".join([    r"редактор\s+субтитров",    r"корректор\s+",    r"субтитры\s+(?:сделал|подготовил|добавил|от\s+подписч)",    r"спасибо\s+за\s+просмотр",    r"продолжение\s+следует",    r"подпис(?:ыв|ка)\w*\s+на\s+канал",    r"приятного\s+просмотра",    r"\[\s*музыка\s*\]",    r"\[\s*тишина\s*\]",    r"Subtitles?\s+by",]), re.IGNORECASE)

Это не выдуманный список — это реальные строки, прилетавшие из тишины на линии. Без фильтра оператор получал карточки лидов, где человек якобы благодарил за просмотр и просил подписаться на канал.

Причина понятна: модель обучалась в том числе на субтитрах, и на низкоинформативном входе сваливается в самые частотные фразы обучающей выборки. Лечится только постфильтром.


Часть VI. День, когда всё сломалось: параллельность

Дальше — часть, ради которой я вообще сел это писать.

Архитектурно всё выглядело правильно. Зачем держать линию, пока идёт распознавание? Захватываем аудио живьём, сразу кладём трубку, а транскрибируем в фоне, пока робот уже звонит следующему. Классический конвейер, полная утилизация ресурсов.

Это сломало приветствие.

Что произошло

Машина — Intel i7-6600U: 2 физических ядра, 4 логических. Даже в покое хост сидел на ~57% CPU.

Фоновая инференция medium занимает в среднем ~49 секунд и загружает все ядра полностью. И она накладывалась на воспроизведение приветствия следующему абоненту — а это реалтаймовая задача: 8 секунд звука надо отдать в устройство ровно за 8 секунд, без права опоздать.

Ядер не хватало, буфер не успевал наполняться, начинались underrun’ы. Абонент вместо приветствия слышал рванину.

Хуже всего, что это было невидимо: sd.play() из sounddevice про underrun’ы не сообщает. В логах чисто, метрики зелёные, звонки идут.

Как обнаружилось

Не по мониторингу. По расшифровкам.

В ответах людей стали появляться «что-то кончилось?», «алло, не слышно», «повторите». Люди отвечали не на приветствие, а на его обрывки. Классификатор при этом видел бессмыслицу и отправлял всё в ручной разбор — то есть косвенный ущерб был ещё и в том, что оператор разгребал мусор.

Это, пожалуй, главный практический вывод проекта: в голосовых системах пользовательские реплики — это тоже телеметрия. Люди буквально проговаривают вам ваши баги, если их читать.

Решение против инстинкта

Конвейер сделан строго последовательным:

dial → greeting → capture → HANGUP (линия свободна) →transcribe на освободившемся CPU → карточка в Telegram → следующий номер

Очередь работ с concurrency = 1. Трубка по-прежнему кладётся до распознавания, поэтому whisper молотит уже при разорванном соединении и никого не держит. Но следующий номер не набирается, пока не готов транскрипт и не отправлена карточка. Инференс никогда не конкурирует с воспроизведением.

Формально пропускная способность ниже. Практически «быстрее, но половина звонков испорчена» — это не быстрее. На слабом железе последовательный конвейер, где реалтаймовая задача никогда не делит ядра с тяжёлым счётом, оказался единственным работающим вариантом.

Побочный эффект, о котором стоит знать: HTTP-ручка «позвонить сейчас» теперь отвечает не сразу, а после отправки карточки. Синхронность конвейера протекает наружу в API — это цена, и её надо принимать осознанно.


Часть VII. Что получилось в цифрах

С реального прогона:

  • темп — ~83 секунды на звонок целиком, от набора до карточки в Telegram;

  • распознавание medium~49 секунд в среднем, максимум 168 секунд под конкуренцией за CPU;

  • за первый неполный день — 147 обработанных номеров, дальше кампания была остановлена на разбор результатов;

  • целевые 300 звонков в день при таком темпе укладываются в 7–8 часов работы.

Точность распознавания на русском оказалась отличной — это была наименьшая из проблем.

Самое слабое звено — классификатор. Он на правилах: ловит явные «да» и «нет», всё остальное уходит в unknown и на ручной разбор оператором. Заменяется на LLM одной переменной окружения (интерфейс Classifier для того и сделан), но это уже вопрос бюджета, а не инженерии.

Что оператор получает в Telegram по каждому звонку: лид и кампания, вся исходная строка из импорта, расшифровка ответа, вердикт и путь к записи. Слушать записи руками не нужно — в этом и был смысл.


Часть VIII. Грабля не по теме, съевшая вечер

Бот работает под watchdog-скриптом в автозапуске. После очередной перезагрузки он молча не поднялся. Ни ошибки, ни строчки в логе — скрипт умирал раньше, чем успевал что-либо записать.

Причина: в PowerShell-скрипте были длинные тире , а файл сохранён в UTF-8 без BOM. Windows PowerShell 5.1 на хосте с кодировкой cp1251 без BOM считает файл однобайтовым, спотыкается на многобайтовом символе и падает при разборе — до выполнения первой строки, то есть до любого логирования.

Лечение: весь скрипт переведён в чистый ASCII, длинные тире заменены на дефисы. Если в подобном файле когда-нибудь снова понадобится не-ASCII — только UTF-8 с BOM.

Если автозапуск не сработал, а в логах пусто — подозревайте не логику, а кодировку.


Часть IX. Честные ограничения

Раз уж разговор инженерный, ограничения тоже инженерные:

  • Не масштабируется горизонтально. Один аккаунт WhatsApp = одна параллельная попытка. Нужна живая Windows-сессия с незасвёрнутым окном. Для объёма нужен пул машин и аккаунтов — линейно по железу.

  • Linux и headless невозможны в принципе. Нужен настоящий десктоп с виртуальными аудиоустройствами.

  • Упирается в CPU одной машины. На двух ядрах конвейер обязан быть последовательным; на нормальном железе можно вернуть параллельность и заметно поднять темп.

  • Ломается при обновлении приложения. Меняются automation_id, перекладывается вёрстка, исчезает таймер. Часть защиты уже заложена (несколько идентификаторов, поиск по имени на двух языках, два независимых сигнала пикапа), но обновление интерфейса всегда риск.

  • Серая зона по правилам платформы. В README проекта это записано прямо: не для холодных баз, только по согласию получателя. Ни один технический трюк этого не отменяет.

Что бы я сделал иначе, начиная заново: сразу заложил бы последовательный конвейер и не тратил время на «правильную» параллельную архитектуру, которая на этом железе была обречена. И сразу поставил бы фильтр галлюцинаций — на него ушло непозволительно много недоумения.


Итог

Получилась связка из четырёх слоёв, каждый лезет в приложение на своём уровне:

  1. Playwright по CDP — в WebView2 внутри WinUI3-оболочки: навигация по чатам, клик по кнопке звонка.

  2. Python + pywinauto (UI Automation) — нативное окно звонка: детект поднятия трубки и гарантированное завершение.

  3. Виртуальные аудиокабели VB-Audio — вместо микрофона и динамиков, с per-app привязкой через аудиополитику Windows и шумовым полом против VAD.

  4. faster-whisper тёплым процессом — расшифровка локально, с фильтром галлюцинаций.

Самое ценное, что я вынес, лежит не в коде: закэшированный UI-хэндл протухает молча, реалтаймовый звук не прощает соседей по процессору, пользователи проговаривают вам ваши баги, а PowerShell не прощает отсутствия BOM.

Если у вас есть опыт автоматизации WinUI3-приложений через UIA — особенно интересно, как вы справляетесь с пересобираемыми окнами и плавающими automation_id между сборками. И отдельно любопытно, ловил ли кто-нибудь ещё гейтинг WebRTC VAD на подаче предзаписанного аудио и решал ли это иначе, чем шумовым полом.

ссылка на оригинал статьи https://habr.com/ru/articles/1062882/