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

image

Если интересно, мой опыт реализации на python вместе с кодом под катом.

Знакомый предоставляет услуги технической поддержки и обслуживания компьютерной техники. Характер работы сотрудников – разъездной. Отдельного диспетчера нет, все звонки принимают сами сотрудники в целях экономии. Бывают ситуации, когда сотрудник не может ответить на звонок (в дороге, в диалоге с клиентом) или на сотрудника поступают претензии от клиента (сделал не то или не все, что просили). Такие ситуации надо «разруливать». В общем, надо было ему как-то централизовать прием звонков, не принимая на работу диспетчера.

У знакомого все инциденты и изменения записываются и управляются в соответствии с требованиями ITIL. Автоматизированы процессы с помощью easla.com. Не хватало только звонков.

Задача


Ни о каком полноценном call-центре речь не шла, т.к. разбор звонков осуществляется «постфактум». Поэтому требования были простые:

  • Записывать в базу информацию о звонке (номер, дату, продолжительность)
  • Записывать статус звонка (отвечен, без ответа, занято)
  • Записывать разговор.


В предоставленной в easla.com базе данных уже был создан объект «Звонок» и все необходимые атрибуты. Оговорили только статусы. Добавили статус «Невозможен» на тот случай, если на счете телефона кончились деньги.

Решение


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

Было принято решение использовать Twisted в качестве FastAGI сервера, который бы получал информацию о совершенном звонке и передавал информацию в easla.com посредством SOAP. Все процедуры описаны в руководстве администратора системы.

Разговор записывается с помощью команды MixMonitor, в качестве имени файла используем переменную ${UNIQUEID}.
По окончании разговора останавливаем запись разговора и передаем управление FastAGI серверу:
exten => h,1,StopMixMonitor
exten => h,n,AGI(agi://127.0.0.1:4573)

Для реализации протокола FastAGI использовал библиотеку starpy. Информацию о продолжительности звонка получаем через CDR-записи. После получения всей необходимой информации в отдельном потоке записываем её в easla.com.

Получаем информацию о звонке
def fastAgiMain( agi ):
    sequence = fastagi.InSequence()
    # Указываем какие переменные необходимо получить.
    cdr_vars = {
            'CDR(start)':'',
            'CDR(disposition)}':'',
            'CDR(duration)':'',
            'CDR(end)':'',
            'DIALSTATUS':'',
            }
    # Получаем информацию и там же отправляем её в easla.com в отдельном потоке
    sequence.append(sendCDR, None, agi, cdr_vars, iter(cdr_vars))
    # После возвращаем упроавление в asterisk
    sequence.append(agi.finish)
    def onFailure( reason ):
        log.error( "Failure: %s", reason.getTraceback())
        agi.finish()
    return sequence().addErrback( onFailure )

# Рекурсивная функция, которая получает все переменные указанные в cdr_vars
def sendCDR(result, agi, cdr_vars, keys):

    def setVar(result, key):
        cdr_vars[key] = result

    def notAvailable(reason, key):
        print "key " + key + " not found"

    try:
        key = keys.next()
    except StopIteration, err:
        duration = str(timedelta(seconds=int(cdr_vars['CDR(duration)'])))
	# Не используя getVariable можно получить callerid и uniqueid
        caller_id = agi.variables['agi_callerid']
        wav_file = '/data/wav/' + agi.variables['agi_uniqueid'] + '.wav'
        status = cdr_vars['DIALSTATUS']
        # В отдельном потоке отправляем информацию о звонке
        thread = Thread(target=sendCallInfo, args=(caller_id, duration, wav_file, status))
        thread.start()
        return None
    else:
        return agi.getVariable(key) \ # Получаем переменную key
			.addCallback(setVar, key) \ # Записываем её в cdr_vars
			.addErrback(notAvailable, key) \ # Если ошибка во время получения переменной key
			.addCallback(sendCDR, agi, cdr_vars, keys) # Вызываем себя еще раз



После того, как вернули asterisk-у управление звонком, можно заняться конвертированием wav в mp3 и отправкой информации в easla.com. Здесь необходимо пояснить, почему не используем MixMonitor для конвертирования, как предлагается во многих руководствах. MixMonitor запускает сторонние приложения отдельным процессом и никак не информирует FastAGI о том, что приложение выполнилось, и запросто может случится так, что к моменту отправки информации о звонке не будет доступа к mp3 файлу. Для конвертирования используется библиотека pydub, а suds как SOAP клиент.

Отправляем

def sendCallInfo(callid, callduration, wav_file ,status):
    raw_params = {
        'incoming_call_number': callid,
        'incoming_call_time': callduration,}
    if status:
        if status == 'ANSWER':
            raw_params['status'] = 'incoming_call_answered'
        if status == 'BUSY':
            raw_params['status'] = 'incoming_call_busy'
        if status == 'NOANSWER':
            raw_params['status'] = 'incoming_call_unanswered'
        if status == 'CANCEL':
            raw_params['status'] = 'incoming_call_unanswered'
        if status == 'CONGESTION':
            raw_params['status'] = 'incoming_call_congestion'

    url = 'http://easla.com/user/soap'
    client = Client(url)
    client.service.login('login','password')

    call_management_proc = client.service.getProcess('call_management')
    incoming_call_def = client.service.getObjectdef(call_management_proc,
            'incoming_call', 0)

    keyval_array = client.factory.create('KeyValuesPairSoapArray')
    # Наполняем массив KeyValuesPairSoapArray для отправки в easla.com
    for key, value in raw_params.iteritems():
        keyval = client.factory.create('KeyValuesPairSoap')
        keyval.key = key
        keyval.values.item.append(value)
        keyval_array.item.append(keyval)

    # Создаем объект входящий звонок в easla.com
    incoming_call_obj = client.service.createObjectref(incoming_call_def,
            None, keyval_array)

    if os.path.exists(wav_file):
        # asterisk может еще не освободить файл
        while is_locked(wav_file):
            time.sleep(1)

        mp3_file = wav2mp3(wav_file)
        with open(mp3_file, "rb") as image_file:
            encoded_string = base64.b64encode(image_file.read())

        if encoded_string:
            # Создаем атрибут в котором будет лежать mp3 файл
            file_attr = client.factory.create('KeyValuePairSoapArray')
            file_name = client.factory.create('KeyValuePairSoap')
            file_content = client.factory.create('KeyValuePairSoap')
            file_name.key = 'srcname'
            file_name.value = os.path.basename(mp3_file)
            file_content.key = 'content'
            file_content.value = encoded_string
            file_attr.item.append(file_name)
            file_attr.item.append(file_content)
            # Добавляем атрибут к объекту
            client.service.addFile(incoming_call_obj, 'incoming_call_file',
                        file_attr)
            if wav_file:
                os.remove(wav_file)
            if mp3_file:
                os.remove(mp3_file)



Работу модуля удалось обкатать в первую же неделю эксплуатации. Сперва на счете не хватало средств, и успешно проверили регистрацию звонков со статусом «Невозможен». Потом счет пополнили и проверили регистрацию звонков с остальными статусами.
Выглядит реестр входящих звонков как-то так:



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

Если кому-то пригодится такое решение, буду рад.
Поделиться с друзьями
-->

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


  1. agic
    26.05.2016 09:25

    mixmonitor и curl? решит все ваши проблемы


    1. IlyasR
      26.05.2016 09:27

      Да MixMonitor, а почему бы и нет? Что Вас смущает?


      1. agic
        26.05.2016 09:30

        скрипт хороший. Но зачем отдавать все во внешнюю обработку, если можно сделать штатными средствами asterisk.
        {CURL(https://dsgf.ru/api/RingStat/?status=1&input=${CALLERID(dnid)}&phone=${CI}&client=${HASH(abon,clientid)})&dom=${HASH(abon,domid)}});

        здесь я передаю например кучу внешних параметров на сторонний сайт


      1. agic
        26.05.2016 09:32

        MYSQL(Query resultid ${connid} INSERT INTO `asterisk`.`message` (`num`, `listen`, `time`) VALUES (${CALLERID(num)}, '1', current_timestamp););

        так например сразу можно положить и что угодно в базу.


    1. IlyasR
      26.05.2016 09:33
      +1

      К сожалению, нет. Из MixMonitor мы не получим продолжительность разговора, начала и конец.


      1. agic
        26.05.2016 09:35

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


        1. IlyasR
          26.05.2016 09:45

          Совсем без скриптов не обойтись, мне ведь необходимо было отправить данные через SOAP. А так не спорю, решение не единственное.


          1. agic
            26.05.2016 10:35

            насчет soap единственный выход


  1. arheops
    27.05.2016 09:27

    Не очень хорошее решение. В определенных условиях(медленный днс или проблемы коннекта к серверу) может положить сервер телефонии.
    Правильное решение — читать информацию из таблички cdr(при необходимости добавочные поля ложить туда же) и делать все что вам надо внешним скриптом.
    Говорю по своему опыту, опыта работы с астериском 15+ лет.


    1. IlyasR
      27.05.2016 09:45

      Как раз для этого все потенциально опасные операции вынесены в отдельный поток и никак не повлияют на работу asterisk:

              thread = Thread(target=sendCallInfo, args=(caller_id, duration, wav_file, status))
              thread.start()
      

      Единственное узкое место может быть в доступности самого FastAGI сервера. Вполне возможно, что на очень нагруженных asterisk серверах использовать FastAGI вызовет проблемы, но для небольшой организации, которая совершает 200-300 звонков в день, работает без сбоев.


      1. arheops
        27.05.2016 09:52

        Может и так, но вы сделали много избыточного кода. По сути справляется простой bash скрипт в один поток. Просматриваете cdr старше последнего обработаного, обрабатываете, перемещаете указатель.
        Кстати, такие непрофильные сервисы на сервере телефонии надо запускать через nice. Иначе может получится затык в звуке в момент старта конвертации.


        1. IlyasR
          27.05.2016 10:08

          Мне не очень нравится идея постоянно опрашивать базу на наличие новой записи, можно кончено триггер повесить на изменение таблицы, но насколько я знаю во встроенной базой asterisk триггер не установить(могу ошибаться), из-за этого пришлось бы складывать cdr в sql базу и пришлось бы обеспечивать бесперебойную работу этой базы. При недоступности sql базы asterisk вообще не сможет работать, а при недоступности fastAGI сервера у нас будут только ошибки сыпаться в лог, а звонки будут работать.
          Что касаемо конвертирования в mp3, то лучше её делать вообще на другой машине :) благо fastAGI для этого и делали.


          1. agic
            27.05.2016 11:31

            а повесить на базу процедуры при добавлении новой записи? при недоступности базы куда складываются cdr астериск тупо сыпить ошибки и успешно работает


          1. arheops
            27.05.2016 19:31

            Кто вам такую ерунду сказал? Ядро астериска вообще не зависит от модуля cdr. Если у вас упадет mysql(что ОЧЕНЬ маловероятно), астериск даже не почешется.
            В my.cnf добавляете query cache 10M, и вот у вас уже этот скрипт вообще не трогает диск до тех пор, пока не появится новая запись.

            Можно и без базы — через tail /var/log/asterisk/cdr-csv/Master.csv |yourscript


        1. agic
          27.05.2016 11:26

          солидарен с вами полностью.


      1. agic
        27.05.2016 11:29

        у меня есть в обслуживании сервера у которых одновременно столько разговоров идет(200-400), так там такая вещь вероятнее всего его положит. не позволяем такой роскоши…


  1. MikeBooker
    28.05.2016 17:37

    Не понял, а чем этот «велосипед» лучше, чем, например, штатный модуль CDR Reports у FreePBX и т.д.?