Если интересно, мой опыт реализации на 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)
arheops
27.05.2016 09:27Не очень хорошее решение. В определенных условиях(медленный днс или проблемы коннекта к серверу) может положить сервер телефонии.
Правильное решение — читать информацию из таблички cdr(при необходимости добавочные поля ложить туда же) и делать все что вам надо внешним скриптом.
Говорю по своему опыту, опыта работы с астериском 15+ лет.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 звонков в день, работает без сбоев.arheops
27.05.2016 09:52Может и так, но вы сделали много избыточного кода. По сути справляется простой bash скрипт в один поток. Просматриваете cdr старше последнего обработаного, обрабатываете, перемещаете указатель.
Кстати, такие непрофильные сервисы на сервере телефонии надо запускать через nice. Иначе может получится затык в звуке в момент старта конвертации.IlyasR
27.05.2016 10:08Мне не очень нравится идея постоянно опрашивать базу на наличие новой записи, можно кончено триггер повесить на изменение таблицы, но насколько я знаю во встроенной базой asterisk триггер не установить(могу ошибаться), из-за этого пришлось бы складывать cdr в sql базу и пришлось бы обеспечивать бесперебойную работу этой базы. При недоступности sql базы asterisk вообще не сможет работать, а при недоступности fastAGI сервера у нас будут только ошибки сыпаться в лог, а звонки будут работать.
Что касаемо конвертирования в mp3, то лучше её делать вообще на другой машине :) благо fastAGI для этого и делали.agic
27.05.2016 11:31а повесить на базу процедуры при добавлении новой записи? при недоступности базы куда складываются cdr астериск тупо сыпить ошибки и успешно работает
arheops
27.05.2016 19:31Кто вам такую ерунду сказал? Ядро астериска вообще не зависит от модуля cdr. Если у вас упадет mysql(что ОЧЕНЬ маловероятно), астериск даже не почешется.
В my.cnf добавляете query cache 10M, и вот у вас уже этот скрипт вообще не трогает диск до тех пор, пока не появится новая запись.
Можно и без базы — через tail /var/log/asterisk/cdr-csv/Master.csv |yourscript
agic
27.05.2016 11:29у меня есть в обслуживании сервера у которых одновременно столько разговоров идет(200-400), так там такая вещь вероятнее всего его положит. не позволяем такой роскоши…
MikeBooker
28.05.2016 17:37Не понял, а чем этот «велосипед» лучше, чем, например, штатный модуль CDR Reports у FreePBX и т.д.?
agic
mixmonitor и curl? решит все ваши проблемы
IlyasR
Да MixMonitor, а почему бы и нет? Что Вас смущает?
agic
скрипт хороший. Но зачем отдавать все во внешнюю обработку, если можно сделать штатными средствами asterisk.
{CURL(https://dsgf.ru/api/RingStat/?status=1&input=${CALLERID(dnid)}&phone=${CI}&client=${HASH(abon,clientid)})&dom=${HASH(abon,domid)}});
здесь я передаю например кучу внешних параметров на сторонний сайт
agic
MYSQL(Query resultid ${connid} INSERT INTO `asterisk`.`message` (`num`, `listen`, `time`) VALUES (${CALLERID(num)}, '1', current_timestamp););
так например сразу можно положить и что угодно в базу.
IlyasR
К сожалению, нет. Из MixMonitor мы не получим продолжительность разговора, начала и конец.
agic
мы его можем получить из CDR и потом положить куда угодно, я например ложу в базу и отправляю на сторонний сервис данные о CDR. CDR
это всего лишь поля куда и как вы их положите не имеет значение, может и в curl обернуть. Я лишь хочу сказать что есть и другие подходы, не используя внешние скрипты
IlyasR
Совсем без скриптов не обойтись, мне ведь необходимо было отправить данные через SOAP. А так не спорю, решение не единственное.
agic
насчет soap единственный выход