Привет, Хабр! Я Катя Саяпина, менеджер продукта МТС Exolve. В прошлом посте я рассказывала, как подключить второй фактор аутентификации через звонок робота, который диктует код. А еще — как реализовать рабочее решение на Django с использованием API МТС Exolve на примере сайта бронирования.

Сегодня продолжим тему. Покажу, как это решение можно масштабировать и оптимизировать:

  • уменьшить затраты за счет сохранения аудиокодов;

  • повысить надежность доставки с помощью fallback-канала по SMS;

  • автоматически подобрать голос и язык диктовки.

Флоу аутентификации

В доработанной версии системы процесс выглядит так:

  1. Пользователь запрашивает код для входа.

  2. Через код автоматически определяются параметры диктовки:

    • язык — по профилю пользователя с помощью get_language() из Django.

    • голос — через внешний сервис Gender API.

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

  4. Если в настройках задано SAVE_RECORDS=false, совершаем звонок с генерацией озвучки всегда, как мы делали в прошлой публикации.

  5. Делаем звонок через МТС Exolve, диктуем код.

  6. Проверяем статус звонка. Если соединение с абонентом было выполнено, завершаем процесс. В противном случае можно запросить код в SMS как через резервный канал.

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

Выбор языка и голоса для синтеза речи

Словарь GENDER_AND_LANGUAGE_MAP содержит список поддерживаемых языков и голосов для каждого языка. Метод SynthesizeAndSave, который мы используем для предварительной генерации и сохранения аудио, работает с шестью языками: русским, английским, немецким, ивритом, казахским и узбекским.

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

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

GENDER_AND_LANGUAGE_MAP = {'ru': [1, 2], 'en': [17], 'de': [16], 'he': [18], 'kk': [19], 'uz': [21]}




@dataclass
class VoiceSettingsBase(JSONWizard):
   class _(JSONWizard.Meta):
       skip_defaults = True


   lang: int | None = None
   voice:  int | None = None
   emotion: int | None = None
   speed: float | None = None


   def set_voice(self, name, language):
       dictor_indexes = 1
       if not settings.SAVE_RECORDS:
           language = 'ru'  # Only Russian is supported in online generation.
       if language in GENDER_AND_LANGUAGE_MAP.keys():
           dictor_indexes = GENDER_AND_LANGUAGE_MAP.get(language, settings.LANGUAGE_CODE)
       dictor_id = dictor_indexes[0]
       if len(dictor_indexes) > 1 and len(name):
           gender = detect_gender(name)
           dictor_id = dictor_indexes[gender]
       self.voice = dictor_id




@dataclass
class TTS:
   text: str = ""

Функция set_voice автоматически выбирает подходящий голос в зависимости от языка и пола пользователя. Если для языка предусмотрен только один голос, используется он. Если несколько, определяем пол по имени через Gender API.

def detect_gender(name):
   r = requests.post(rf'https://gender-api.com/get?name={name}&key={gender_api_key}')
   if r.status_code == 200:
       data = json.loads(r.text)
       if data['gender'] == 'female':
           return 1
   return 0

⚠️ Gender API — внешний сервис, 100 бесплатных запросов в день. Можно заменить на локальную логику или отказаться от точного распознавания, если это не критично. 

Генерация и сохранение аудио

Теперь подготовим параметры TTS и создадим или повторно используем аудиозапись через МТС Exolve. Сначала собираем настройки — голос, язык, скорость и так далее — формируем уникальное имя full_name и проверяем его через GetList. Если запись найдена, используем ее resource_id, если нет — вызываем SynthesizeAndSave.

Имя формируем как voice_{voice}_code_{text}, но в продакшене лучше использовать хеш вместо текста для безопасности. Параметры speed, emotion, loudness_normalization фиксируем в кеше, чтобы каждый их вариант имел свою запись.

Функция check_existence() обеспечивает идемпотентность: повторный вызов с тем же именем вернет один и тот же resource_id. 

Меняя шаблон фразы, обновляйте версию имени. При сетевых ошибках делайте повторы с паузами, для 4x ошибок — только логируйте.

В момент отправки кода система проверяет, есть ли в МТС Exolve запись с тем же кодом и созданная тем же диктором. Если нет, генерируется новое голосовое сообщение, а потом сохраняется для повторного использования.

@dataclass
class VoiceSettings(VoiceSettingsBase):
   loudness_normalization: int | None = None




@dataclass
class SynthParams(JSONWizard, TTS):
   class _(JSONWizard.Meta):
       skip_defaults = True
       key_transform_with_dump = 'SNAKE'
   full_name: str = ""
   voice_settings: VoiceSettings = field(default_factory=VoiceSettings)


   def __post_init__(self):
       if self.full_name == "":
           voice = self.voice_settings.voice or 1
           self.full_name = f'voice_{voice}_code_'+self.text

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

def check_existence(synth_params: SynthParams):
   name = synth_params.full_name
   r = requests.post(r'https://api.exolve.ru/media/v1/GetList', headers={'Authorization': 'Bearer ' + exolve_api_key},
                     data=json.dumps({'name': name}))
   ans = json.loads(r.text)
   if len(ans['media_records']):
       return ans['media_records'][0]["resource_id"]
   return ""




def create_record(synth_params: SynthParams):
   resource_id = check_existence(synth_params)
   if len(resource_id):
       return resource_id
   synth_params = synth_params.to_json()
   r = requests.post(r'https://api.exolve.ru/media/v1/SynthesizeAndSave', headers={'Authorization': 'Bearer ' + exolve_api_key},
                     data=synth_params)
   if r.status_code != 200:
       raise
   ans = json.loads(r.text)
   return ans["resource_id"]

Если запись по уникальному имени в МТС Exolve найдена, возвращаем ее resource_id и используем для звонка. Если нет — создаем новую через SynthesizeAndSave, а потом сохраняем resource_id для следующих обращений. Такой подход обеспечивает предсказуемость и упрощает логику повторных вызовов.

Отправка звонка пользователю

Для формирования запроса на звонок и проигрывания аудио рассмотрим два сценария: с готовой аудиозаписью (SAVE_RECORDS=True) и синтез речи прямо во время вызова. 

Класс CallParams собирает параметры вызова: исходящий и входящий номера, а также одно из двух полей — service_id или tts. В зависимости от сценария JSON в запросе включает только одно из них: service_id, если используется предзаписанное сообщение, или tts, если речь синтезируется во время звонка. Класс сам исключает поля со значениями по умолчанию, чтобы структура данных оставалась корректной. Перед отправкой номера проходят проверку и форматирование, а потом функция make_call отправляет запрос в МТС Exolve API для совершения звонка.

@dataclass
class TTSCall(VoiceSettingsBase, TTS):
   volume: int | None = None




@dataclass
class CallParams(JSONWizard):
   class _(JSONWizard.Meta):
       skip_defaults = True
       key_transform_with_dump = 'SNAKE'
   source: str = ""
   destination: str = ""
   tts: TTSCall = field(default_factory=TTSCall)
   service_id: str = ""


   def __post_init__(self):
       self.source = verify_number(self.source)
       self.destination = verify_number(self.destination)


   def set_message_id(self, service_id: str):
       self.tts = TTSCall()  # Erase information
       self.service_id = service_id

В параметрах звонка можно указать два варианта данных: 

  • service_id — идентификатор заранее подготовленного аудиофайла, который уже хранится в МТС Exolve;

  • TTS — текст и настройки для генерации речи прямо во время звонка. 

Это позволяет выбирать, использовать ли заранее сгенерированные сообщения или синтезировать новые на лету в зависимости от сценария.

def create_voice_SMS(resource_id: str):
   r = requests.post(r'https://api.exolve.ru/voice-message/v1/Create', headers={'Authorization': 'Bearer ' + exolve_api_key},
                     data=json.dumps({'media_id': resource_id, 'name': resource_id}))
   if r.status_code != 200:
       raise
   ans = json.loads(r.text)
   return ans["id"]

def make_call(destination: str, text: str, name: str, language: str):
   tts_call = TTSCall(text=text)
   call_params = CallParams(source=application_phone, destination=destination, tts=tts_call)
   if settings.SAVE_RECORDS:
       voice_setts = VoiceSettings()
       voice_setts.set_voice(name, language)
       synth_params = SynthParams(text=text, voice_settings=voice_setts)
       resource_id = create_record(synth_params)
       message_id = create_voice_SMS(resource_id)
       call_params.set_message_id(message_id)
   else:
       tts_call.set_voice(name, language)
   call_params = call_params.to_json()
   r = requests.post(r'https://api.exolve.ru/call/v1/MakeVoiceMessage', headers={'Authorization': 'Bearer ' + exolve_api_key},
                     data=call_params)
   return r

Если SAVE_RECORDS=True, то при  звонке используется уже подготовленное и сохраненное аудио, которое хранится в МТС Exolve и просто подставляется в вызов. Если False, речь синтезируется прямо в момент звонка и код проговаривается роботом в реальном времени.

Fallback: отправка SMS, если звонок не удался

Если звонок не проходит — например, абонент вне зоны действия сети, номер занят или пользователь не отвечает — важно иметь резервный способ доставки кода. В этой части мы добавляем fallback-канал в виде SMS. После завершения звонка проверяется его статус, и если результат неуспешный, формируем сообщение с кодом и отправляем его через API SMS-сервиса. Это гарантирует, что пользователь получит код даже при проблемах с голосовой доставкой.

В дальнейшем можно расширить логику: использовать разные тексты для разных ошибок, нескольких SMS-провайдеров или настраивать последовательность отправки: звонок, затем SMS, а при необходимости — push-уведомление.

@dataclass_json
@dataclass
class SMSParams:
   number: str = ""
   destination: str = ""
   text: str = ""

Функции отправки SMS и совершения звонка похожи:

def send_SMS(destination: str, text: str):
   payload = SMSParams(destination=destination, text=text).to_json()
   r = requests.post(r'https://api.exolve.ru/messaging/v1/SendSMS', headers={'Authorization': 'Bearer '+sms_api_key}, data=payload)   return r

Соединяем код с бэкендом

Теперь подключим наши функции звонков и SMS к существующему бэкенду. Модифицируем gateways.py. Сначала импортируем методы совершения звонка и отправки сообщения:

from .utils import make_call, send_SMS
from django.contrib.auth.models import User
from django.utils.translation import get_language

В gateways.py определен класс Messages. Мы добавим в него методы make_call и send_sms для совершения звонка и отправки SMS. Все параметры обрабатываются через дата-классы, которые создаются внутри функций make_call и send_SMS.

Функция get_language() возвращает текущий язык интерфейса пользователя. Его имя извлекаем из таблицы User по индексу, связанному с используемым для аутентификации устройством.

@classmethod
def make_call(cls, device, token):
   cls._add_message('Making call to %(number)s', device, token)
   destination = str(device.number)
   user_object = User.objects.get(id=device.user_id)
   name = user_object.username
   try:
       r = make_call(destination=destination, text=token, name=name, language=get_language())
       if r.status_code != 200:
           cls._add_message('Failed to make call', device, token)
   except ValueError:
       cls._add_message('Wrong phone number', device, token)


@classmethod
def send_sms(cls, device, token):
   cls._add_message(_('Sending SMS to %(number)s'), device, token)
   try:
       r = send_SMS(destination=str(device.number), text=token)
       if r.status_code != 200:
           cls._add_message('Failed to send SMS', device, token)
   except ValueError:
       cls._add_message('Wrong phone number', device, token)

Настройка проекта

В settings.py добавим параметры: язык по умолчанию, список поддерживаемых языков и флаг SAVE_RECORDS. Эти настройки определяют, как обрабатываются голосовые сообщения:

  • если True — аудио создаются заранее и сохраняются на платформе;

  • если False — синтезируются на лету при каждом звонке.

# default language, it will be used, if django can't recognize user's language
LANGUAGE_CODE = 'ru'
# list of activated languages
LANGUAGES = (
   ('ru', 'Russian'),
   ('en', 'English'),
   ('de', 'German'),
   ('he', 'Hebrew'),
   ('kk', 'Kazakh'),
   ('uz', 'Uzbek'),
)
SAVE_RECORDS = True

Что мы получили

Пора резюмировать! Сегодня мы с вами дополнили базовую реализацию двухфакторной аутентификации:

  • предусмотрели автоматический выбор голоса и языка;

  • добавили сохранение аудио для повторного использования записей и снижения нагрузки на систему;

  • реализовали резервный SMS-канал для случаев, когда звонок не проходит;

  • разделили логику на отдельные шаги: подготовка параметров, звонок, проверка статуса и fallback.

Теперь система стала более гибкой и готовой к масштабированию. В будущем стоит добавить логирование всех действий, ретраи и автоматизировать fallback, чтобы SMS отправлялось автоматически при неуспешном звонке. Еще можно подключить push-уведомления и мониторинг метрик, чтобы контролировать качество доставки и быстро реагировать на сбои.

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