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

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

В этом материале соберём такой сценарий на Python, используя вместо базы данных Битрикс24. Решение берёт контекст из CRM в момент звонка и через МТС Exolve соединяет клиента, исполнителя или поддержку. 

Общая схема работы

В этом сценарии единый промежуточный номер становится публичной точкой входа для звонка. Исполнитель звонит клиенту через этот номер. Когда клиент перезванивает, приложение смотрит текущую сделку в Битрикс24 и выбирает адресата. Так коммуникация остаётся привязанной к заказу и ответственному исполнителю.

  1. Исполнитель входит в личный кабинет по коду из СМС. Затем выбирает заказ и нажимает кнопку «Связаться с клиентом». Далее сервис собирает его рабочий контекст из Битрикс24: активные сделки, адрес и номер клиента

  2. Когда контекст собран, МТС Exolve через Callback API звонит клиенту и исполнителю, и затем соединяет их через промежуточный номер

Стек: Python 3.10+, Streamlit, Flask, Битрикс24 REST API, SMS API и Callback API МТС Exolve.

Архитектура решения

У сервиса нет своей базы данных и очереди. В Streamlit хранится краткоживущее состояние авторизации, а рабочее состояние заказов остаётся в Битрикс24. За счёт этого приложение связывает CRM и телефонию и не дублирует бизнес-данные.

Кабинет исполнителя собран в app.py и authservice.py. App.py отвечает за интерфейс, состояние сессии и действия пользователя. Authservice.py генерирует одноразовый код и отправляет его исполнителю через SMS API. После проверки кода пользователь входит в личный кабинет с заказами.

Интеграция с Битрикс24 и МТС Exolve вынесена в bitrix_integration.py, auth_service.py и exolve_voice.py. Первый модуль читает и изменяет данные Битрикс24. Auth_service.py отвечает за аутентификацию исполнителя по СМС. Exolve_voice.py запускает колбэк-звонки через промежуточный номер. Битрикс24 хранит контекст и заказчика, а МТС Exolve даёт каналы с СМС и звонком.

Входящие звонки обрабатывает webhook_router.py. Модуль принимает вебхук от телефонии, ищет активный контекст клиента и возвращает JSON с адресатом звонка. В config.py собраны ключи и номера.

Пререквизит

Проекту нужны только Python-зависимости и переменные окружения для МТС Exolve и Битрикс24. Здесь запускаются два процесса: кабинет исполнителя на Streamlit и сервер для приёма вебхуков на Flask. Первый обслуживает действия пользователя, второй принимает входящие события от телефонии.

python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

Минимальный набор переменных:

EXOLVE_API_KEY=***
AUTH_POOL_NUMBER=7999XXXXXXX
SINGLE_SERVICE_NUMBER=7800XXXXXXX
SUPPORT_NUMBER=7800YYYYYYY
BITRIX_WEBHOOK=https://your-domain.bitrix24.ru/rest/...

Для локального теста достаточно поднять оба процесса и отправить вебхук вручную. Для реального входящего трафика Flask должен быть доступен извне.

Шаг 1. Авторизуем исполнителя

Исполнитель вводит свой номер телефона в интерфейсе на Streamlit, сервис генерирует одноразовый код и отправляет его в СМС. Пользователь вводит код в форме, а приложение сравнивает его со значением, которое было временно сохранено в сессии.

# auth_service.py

import requests
from config import Config

def send_flash_call(target_phone: str):
   auth_number = Config.AUTH_POOL_NUMBER


   url = "https://api.exolve.ru/voice/v1/MakeCall"
   headers = {"Authorization": f"Bearer {Config.EXOLVE_API_KEY}"}


   payload = {
       "number": auth_number,
       "destination": target_phone,
       "record": False,
       "time_limit": 5,
   }


   try:
       resp = requests.post(url, headers=headers, json=payload, timeout=5)
       resp.raise_for_status()
       return auth_number[-4:]
   except Exception as e:
       print(f"Ошибка Flash Call: {e}")
       return None

Дальше интерфейс сохраняет сгенерированный код в короткоживущем состоянии и сравнивает его с тем, который пользователь ввёл в форму.

# app.py

if not st.session_state.auth:
   phone = st.text_input("Ваш телефон", placeholder="79990000000")


   if st.button("Получить код доступа"):
       with st.spinner("Звоним..."):
           code = send_flash_call(phone)
           if code:
               st.session_state.verification = code
               st.success("Ждите звонка-сброса. Введите последние 4 цифры.")


   user_code = st.text_input("Код из звонка")
   if st.button("Войти"):
       if user_code == st.session_state.get("verification"):
           st.session_state.auth = True
           st.session_state.phone = phone
           st.rerun()

После проверки кода кабинет сохраняет номер исполнителя в сессии и дальше использует его как ключ для поиска активных сделок в Битрикс24. Отдельная учётная запись здесь не нужна: один и тот же номер работает и как способ входа, и как идентификатор сотрудника для CRM.

Шаг 2. Собираем рабочий контекст исполнителя из Битрикс24

После входа кабинет собирает рабочий контекст на лету из CRM. Сначала ищем в Битрикс24 исполнителя по мобильному номеру. Затем запрашиваем все активные сделки, где этот пользователь назначен ответственным. После этого для каждой сделки добираем контакт клиента и его телефон, чтобы из этих данных собрать карточки в интерфейсе.

# bitrix_integration.py

def get_user_id_by_phone(phone: str):
   method = "user.search"
   params = {"FILTER": {"PERSONAL_MOBILE": phone}}

   resp = requests.post(f"{Config.BITRIX_WEBHOOK}/{method}", json=params, timeout=10)
   resp.raise_for_status()
   result = resp.json().get("result", [])
   return result[0]["ID"] if result else None

def get_active_deals(master_phone: str):
   user_id = get_user_id_by_phone(master_phone)
   if not user_id:
       return []

   params = {
       "filter": {
           "ASSIGNED_BY_ID": user_id,
           "!STAGE_ID": FINAL_STAGES,
       },
       "select": ["ID", "TITLE", "UF_CRM_ADDRESS", "CONTACT_ID"],
   }

   resp = requests.post(f"{Config.BITRIX_WEBHOOK}/crm.deal.list", json=params, timeout=10)
   resp.raise_for_status()

   deals = []
   for item in resp.json().get("result", []):
       contact_id = item.get("CONTACT_ID")
       client_phone = _get_contact_phone(contact_id) if contact_id else None


       if client_phone:
           deals.append(
               {
                   "id": item["ID"],
                   "address": item.get("UF_CRM_ADDRESS", "Адрес не указан"),
                   "title": item["TITLE"],
                   "client_phone_hidden": client_phone,
               }
           )

   return deals

Здесь user_id — это идентификатор исполнителя в Битрикс24, полученный по его номеру телефона. Константа FINAL_STAGES — это список финальных стадий сделки, которые мы исключаем из выборки, чтобы в кабинет попадали только активные заказы.

На выходе сервис получает готовые карточки со сделкой, адресом и телефоном клиента. Поле client_phone_hidden хранит исходный номер клиента из CRM, а не промежуточный номер: подмена происходит только в момент звонка через Callback API МТС Exolve.

Шаг 3. Звоним через единый номер

Когда исполнитель нажимает «Связаться», приложение вызывает метод MakeCallback из API МТС Exolve и передаёт в него три значения: номер сервиса, идентификатор колбэк-ресурса и два плеча вызова: line_1 и line_2. Дальше разговор собирается уже на стороне телефонии.

# exolve_voice.py

import requests
from config import Config

def initiate_masked_call(master_phone: str, client_phone: str):
   url = "https://api.exolve.ru/voice/v1/Callback"
   headers = {"Authorization": f"Bearer {Config.EXOLVE_API_KEY}"}


   payload = {
       "number": Config.SINGLE_SERVICE_NUMBER,
       "destination": master_phone,
       "peer": client_phone,
       "record": True,
   }

   try:
       requests.post(url, headers=headers, json=payload, timeout=5)
   except Exception as e:
       print(f"Callback error: {e}")

Кнопка в кабинете, которая запускает этот вызов.

# app.py

if c1.button("? Связаться", key=f"call_{deal['id']}"):
   initiate_masked_call(
       master_phone=st.session_state.phone,
       client_phone=deal["client_phone_hidden"],
   )
   st.toast("Соединяем... Ждите входящий.")

Шаг 4. Принимаем входящий вебхук и ищем активный контекст клиента

Обратный звонок клиента запускает второй сценарий: теперь нужно понять, кому именно переводить вызов. При входящем звонке МТС Exolve отправляет на наш сервер JSON-RPC запрос с методом getControlCallFollowMe. Из параметра params.numberA сервис берёт номер вызывающего абонента, ищет по нему контакт в Битрикс24 и проверяет активные сделки.

# webhook_router.py

@app.route("/exolve/incoming", methods=["POST"])
def handle_call():
   data = request.json or {}
   caller_phone = data.get("numbers", {}).get("a")


   master_phone = find_master_phone_by_client(caller_phone)

Находим контакт клиента по номеру телефона.

def find_master_phone_by_client(client_phone: str):
   params = {
       "type": "PHONE",
       "values": [client_phone],
       "entity_type": "CONTACT",
   }
   resp = requests.post(
       f"{Config.BITRIX_WEBHOOK}/crm.duplicate.findbycomm",
       json=params,
       timeout=10,
   )
   resp.raise_for_status()
   contacts = resp.json().get("result", {})


   if not contacts:
       return None


   contact_id = contacts[0]

Точка входа здесь одна: номер клиента из numbers.a. По этому номеру сервис поднимает контакт и рабочий контекст клиента в CRM.

Шаг 5. Маршрутизируем звонок исполнителю или в поддержку

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

# webhook_router.py

   deal_params = {
       "filter": {
           "CONTACT_ID": contact_id,
           "!STAGE_ID": FINAL_STAGES,
       },
       "select": ["ASSIGNED_BY_ID"],
   }
   deal_resp = requests.post(
       f"{Config.BITRIX_WEBHOOK}/crm.deal.list",
       json=deal_params,
       timeout=10,
   )
   deal_resp.raise_for_status()
   deals = deal_resp.json().get("result", [])


   if not deals:
       return None

   assigned_id = deals[0]["ASSIGNED_BY_ID"]
   user_resp = requests.post(
       f"{Config.BITRIX_WEBHOOK}/user.get",
       json={"ID": assigned_id},
       timeout=10,
   )

Находим мобильный номер ответственного.

   user_resp.raise_for_status()

   users = user_resp.json().get("result", [])
   if users:
       return users[0].get("PERSONAL_MOBILE")

   return None

Возвращаем команду на перевод звонка или уводим на резервный маршрут.

   redirect_number = master_phone if master_phone else Config.SUPPORT_NUMBER

   return jsonify({
       "id": req_id,
       "jsonrpc": "2.0",
       "sip_id": sip_id,
       "result": {
           "redirect_type": 1,
           "followme_struct": [
               1,
               [
                   {
                       "I_FOLLOW_ORDER": 1,
                       "ACTIVE": True,
                       "NAME": "Assigned master",
                       "REDIRECT_NUMBER": redirect_number,
                       "PERIOD": "always",
                       "PERIOD_DESCRIPTION": "always",
                       "TIMEOUT": 30
                   }
               ]
           ]
       }
   })

На этом этапе Битрикс24 — источник истины для маршрутизации. Не телефония решает, кому звонить, а текущая сделка в CRM. Это удобно как MVP, потому что не нужна своя база соответствий между клиентами и сотрудниками.

Шаг 6. Возвращаем изменение статуса обратно в CRM

После визита исполнитель может закрыть заказ из того же кабинета. Здесь сервис уже записывает в CRM результат выполнения. Так карточка сделки и реальный статус выезда сохраняются сразу после звонка или визита.

# bitrix_integration.py

def closedeal(dealid: str):
   params = {"id": dealid, "fields": {"STAGEID": "WON"}}
   requests.post(f"{Config.BITRIX_WEBHOOK}/crm.deal.update", json=params, timeout=10)

Кнопка в кабинете, которая отправляет это обновление.

# app.py

if c2.button("✅ Выполнил", key=f"done_{deal['id']}"):
   close_deal(deal["id"])
   st.success("Заказ закрыт!")
   st.rerun()

Здесь процесс короткий: интерфейс передаёт идентификатор сделки, а Битрикс24 получает новую стадию WON.

Запуск и проверка

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

python webhook_router.py
streamlit run app.py

Для проверки авторизации в SMS_SENDER укажите зарегистрированный номер отправителя, запросите код в интерфейсе и убедитесь, что СМС дошла на тестовый номер.

После этого можно проверить входящий маршрут вручную. Если в SUPPORT_NUMBER задан тестовый номер, а Битрикс24 не найдёт клиентский номер как активный контакт, сервис должен отдать резервный маршрут:

curl -X POST "http://127.0.0.1:5000/exolve/incoming" \
 -H "Content-Type: application/json" \
 -d '{
   "id": 1,
   "jsonrpc": "2.0",
   "method": "getControlCallFollowMe",
   "params": {
     "sip_id": "7800XXXXXXX",
     "numberA": "79990000000"
   }
 }'

Ожидаемый ответ в минимальном сценарии:

{
 "id": 1,
 "jsonrpc": "2.0",
 "sip_id": "7800XXXXXXX",
 "result": {
   "followme_struct": [
     1,
     [
       {
         "ACTIVE": true,
         "I_FOLLOW_ORDER": 1,
         "NAME": "Assigned master",
         "PERIOD": "always",
         "PERIOD_DESCRIPTION": "always",
         "REDIRECT_NUMBER": "7800YYYYYYY",
         "TIMEOUT": 30
       }
     ]
   ],
   "redirect_type": 1
 }
}

Дальше проверьте основной сценарий: зайдите в Streamlit под номером исполнителя, убедитесь, что подтянулись активные сделки, нажмите Связаться, а затем вручную отправьте вебхук с номером клиента из CRM. Если получили 4xx или пустой маршрут, сначала посмотрите payload и данные в Битрикс24. Если 5xx, проверьте сетевые таймауты, доступность REST-вебхука и корректность номеров в карточках исполнителей и клиентов.

Возможности для развития

  1. Привести номера телефонов к единому формату на входе и выходе. Без этого поиск по Битрикс24 быстро начинает расходиться на разных форматах записи

  2. Добавить подпись или иной способ валидации входящего вебхука. Сейчас эндпоинт доверяет любому POST с подходящим JSON

  3. Вынести коды авторизации из session_state в серверное TTL-хранилище, добавить ограничение частоты отправки СМС и журнал попыток входа

  4. Добавить реатраи с нарастающей задержкой и сквозной идентификатор события для запросов в МТС Exolve и Битрикс24, чтобы видеть, где именно рвётся цепочка

  5. Проверять ответы Битрикс24 и МТС Exolve, логировать сбои и показывать их в интерфейсе, чтобы закрытие заказа или запуск звонка не выглядели успешными только на экране

  6. Определить детерминированные правила маршрутизации для нескольких активных сделок одного клиента вместо правила брать первую

  7. Подготовить наблюдаемость: структурированные логи, алерты по ошибкам интеграций и простой аудит событий по номеру, сделке и сотруднику

В итоге

Получился легковесный сценарий без своей БД. Он работает как связка Битрикс24 и МТС Exolve, где CRM хранит рабочий контекст, а телефония собирает сам разговор.

Для MVP это простой способ быстро запустить безопасный сервис с одним промежуточным номером и не раскрывать личные номера исполнителя и клиента, а вход сотрудников организовать через код в СМС без отдельной учётной системы.

Дальше такой сценарий можно развивать через голосового робота МТС Exolve: с IVR и генерацией речи можно будет уточнять нужный заказ и точнее маршрутизировать звонки, когда у клиента или исполнителя одновременно открыто несколько заказов, при этом сохраняя один и тот же промежуточный номер.

Комментарии (1)


  1. ifap
    30.04.2026 15:26

    Символично, что на иллюстрации Битрикс - CMS, в которую разработчк втихую встроил трекер.