Пример реализации автообзвона (с обработкой ответа (нажатия) отвечающей стороны) с использованием ami asterisk.
Задача:
Автоматизировать оценку качества по предоставленным услугам.
Общая логика решения задачи:
1. Дозвониться
2. Прочитать текст
3. Получить ввод
4. В зависимости от цифры ввода либо перевести звонок на оператора(в очередь кол центра), либо попрощаться
5. Сохранить логи, ответ отвечающей стороны
Технологии:
1. 1С (выборка данных, инициализация вызова, обработка завершения, фронт)
2. asterisk (ami) (телефония)
3. python (бэк, прокси сервер между фронтом и телефонией)
4. Yandex.SpeechKit (синтез речи)
Общая принцип работы:
Средствами 1С регламентное задание делает выборку по предварительно сформированным документам для оценки качества. Из выборки берется телефон для набора, и определяется путь к файлу, который будет в дальнейшем воспроизводится. 1С делает гет запрос в бэк, передавая параметры (телефон, путь к файлу). Бэк получает запрос, парсит параметры, подключается к ами asterisk, вызывает действие "Originate" с указанием параметров(телефон, путь к файлу, контекс диалплана, канала вызывающей стороны), подписывается на события ами. В зависимости от полученных событий (положили трубку, нажали цифры, перевод звонка, не дозвонились и тд) возвращает данные(статус, лог звонка, код ответа) в 1С. 1С в зависимости от полученного ответа выполняет какие-либо действия(закрыть обращение, перенести, добавить запись в историю по обзвону и тд.)
Шаг 1. Синтез речи
Для синтеза речи использовался Яндекс спич. Нужно было сгенировать файлы:
1. 3 файла по подразделением с основным текстом:
здравствуйте, вас беспокоит система контроля качества компании ,
... на этой неделе вы посещали , если вам все
понравилось, просим нажать 1, если у вас есть замечания нажмите 2 и мы
соединим с оператором, нажмите 0 - ... и тд
2. 1 файл с текстом "спасибо"
3. 1 файл с текстом "ожидайте соединения со специалистом"
4. закинуть на астериск, выполнить для преобразования: sox -V generate.wav -r 8000 -c 1 -t al generate.alaw
Файлы сгенерированы статично. Динамичная генерация пока не требуется.
https://tts.voicetech.yandex.net/generate?text=здравствуйте!%20вас%20беспокоит%20система%20контрол
....&format=mp3&lang=ru-RU&speaker=oksana&emotion=good&key=46e933b8-30aa-4383-3&speed=0.9
Шаг 2. диалплан
Инициализация исходящего звонка будет происходить через asterisk ami, далее звонок идет в dial plan.
Сам алгоритм обработки звонка статичен. Для реализации задачи был добавлен контекст и макрос.
[ng_ext_autodial]
exten => _X.,1,Verbose(0,=> Outbound : ${CALLERID(num)} => ${EXTEN})
same => n,Dial(SIP/ng_ext/${EXTEN},,M(after-up,${file_name}))
Отсюда начинается логика звонка. file_name - это имя файла для воспроизведения переданное из бэка, которое в бэке определяется по переданному значению из 1С. После того как вызываемая сторона поднимет трубку, сработает макрос after-up.
[macro-after-up]
exten => s,1,Wait(0.2)
same => n,Goto(retry)
same => n(retry),NoOp
same => n,Read(var_a,${ARG1},1,,,10)
same => n,GotoIf($[${var_a} == 0]?press_zero)
same => n,GotoIf($[${var_a} == 1]?press_one)
same => n,GotoIf($[${var_a} == 2]?press_two)
same => n,Goto(retry)
; same => n(press_any),NoOp
; same => n,SayNumber(${var_a})
; same => n,Goto(exit)
same => n(press_zero),NoOp
same => n,Goto(exit)
same => n(press_one),NoOp
same => n,Goto(exit)
same => n(press_two),NoOp
same => n,Playback(/usr/local/share/asterisk/moh//auto/connect)
same => n,Playback(/usr/local/share/asterisk/moh//auto/sps)
same => n,Dial(SIP/ng_ext/8345XXXXXXXX,,tT)
same => n,Goto(exit)
same => n(exit),NoOp
Логика обработки звонка после поднятия трубки. Читаем файл который передали, ждем код ответа, обрабатываем ввод, при необходимости делаем перевод, прощаемся. С первого взгляда кажется при нажатии 0, 1 мы не говорим спасибо так как в скрипте это не указано. Дело вот в чем: когда бэк инициализирует исходящий вызов мы передаем в ами дополнительное поле Application с указанием "Playback" и установкой параметра для чтения файла с текстом "спасибо", астериск выполнить это чтение после обработки макроса. Если перевели звонок в очередь то до момента чтения контекст уже не дойдет.
Шаг 3. прокси сервер
стартер:
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from common import ami_client
import sys
from common import common
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
"""Handle requests in a separate thread."""
if __name__ == '__main__':
parser = common.createParser()
namespace = parser.parse_args(sys.argv[1:])
api_ones_serv = ami_client.AmiClient()
server = ThreadedHTTPServer((namespace.ip, int(namespace.port)), ami_client.AmiClient_Handler)
print('starting ami client server '+str(namespace.ip)+':'+str(namespace.port)+' (use <Ctrl-C> to stop)')
server.serve_forever()
обработчик запросов:
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
from urllib.parse import urlparse, parse_qs
from common import common
import sys
from common import api_func, path_list
class AmiClient_Exception(Exception):
pass
class AmiClient_Handler(BaseHTTPRequestHandler):
callback = None
def log_message(self, format, *args):
return
def smart_response(self, code, message, headers = []):
self.send_response(code)
for h, v in headers:
self.send_header(h, v)
self.send_header("Content-type", "text/plain; charset=utf-8")
self.end_headers()
if (code != 200):
print(message)
message = message
return self.wfile.write(message.encode())
def do_GET(self):
path = urlparse(self.path).path
qs = urlparse(self.path).query
qs = parse_qs(qs)
res = self.callback(path, qs, self)
class AmiClient():
server = None
parser = common.createParser()
namespace = parser.parse_args(sys.argv[1:])
server_host = namespace.ip
server_port = int(namespace.port)
handler = AmiClient_Handler
pathmap = {}
def __init__(self, caller = None):
self.init_pathmap()
#self.init_post_proc_func()
self.handler.callback = self.callback
#_thread.start_new_thread(self.upd_loop, ())
def register(self, method, path, function):
self.pathmap[path] = function
def register_post_proc_func(self, method, path, function):
self.pathmap[path] = function
def callback(self, path, qs, handler):
while path and path[0] == '/':
func = self.pathmap.get(path)
if func is None:
return handler.smart_response(404, "Не найден метод: "+str(path))
try:
res = func(qs)
except KeyError as e:
return handler.smart_response(500, "Не задано значение параметра: %s" % e)
except ValueError as e:
return handler.smart_response(500, "Ошибка в значении параметра: %s" % e)
except AmiClient_Exception as e:
return handler.smart_response(500, "%s" % e)
except Exception as e:
return handler.smart_response(500, "Неожиданная ошибка: %s" % e)
content_type = "application/json"
if not res:
res = json.dumps([], default=common.json_serial)
else:
res = json.dumps(res, default=common.json_serial)
try:
handler.smart_response(200, res, [
("Content-type", content_type),
("Access-Control-Allow-Origin", "*"),
("Access-Control-Expose-Headers", "Access-Control-Allow-Origin"),
("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"),
])
except socket.error as e:
pass
return
else:
handler.smart_response(401, "Unauthorized call: %s from %s" % (path, client_address))
def init_pathmap(self):
for x in path_list.get():
self.register(x['method'], x['func'], x['handler'])
в этот раз без exec.
обработчик команд, ами клиент:
import datetime
from common import common
import os
import time
from asterisk.ami import AMIClient, AMIClientAdapter
import socket
import re
from common.conf import *
d = {'channel':'', 'channel2':'', 'DTMF':'', 'status':'', 'log':''}
#region interfaces_func
def make_call_auto(param):
tel = kwargs_get(param, 'tel')
file = kwargs_get(param, 'file')
client = AMIClient(address=AMI_ADDRESS, port=AMI_PORT)
future = client.login(username=AMI_USER, secret=AMI_SECRET)
if future.response.is_error():
raise Exception(str(future.response))
adapter = AMIClientAdapter(client)
channel = f'Local/{tel}@ng_ext_autodial'
d['channel'] = channel
action_id = tel
variable = FILE_NAMES[file]
client.add_event_listener(event_listener)
#res_call = simple_call_without_oper(channel, data)
res_call = simple_call_without_oper(adapter, channel, DATA, APP, action_id, variable, tel)
while True:
if d['status']=='Error':
break
elif d['status']=='ANSWER':
break
elif d['status']=='BUSY':
break
elif d['status']=='Success':
break
if not res_call.response is None:
d['status'] = res_call.response.status
time.sleep(0.2)
client.logoff()
#print(d['DTMF'])
#print(d['status'])
return d
#endregion
#region internal_func_bp
def simple_call_with_oper(channel, exten, caller_id, caller_id_name, action_id, timeout='', context='ng_ext_autodial', priority=1):
res = adapter.Originate(Channel=channel, Context=context, Exten=exten, ActionID=action_id, Priority=priority, CallerID=caller_id, CallerIDName=caller_id_name, Timeout=timeout, _callback=callback_response)
return res
def simple_call_without_oper(adapter, channel, data, app, action_id, variable = '', exten='', timeout='45000', context='ng_ext_autodial'):
res = adapter.Originate(Channel=channel, Context=context, Application=app, Exten=exten, ActionID=action_id, Data=data, Timeout=timeout, _callback=callback_response, Variable=variable)
return res
def callback_response(response):
if response.status=='Error':
d['status'] = 'Error'
return None
def event_listener(event,**kwargs):
#print('"%s" "%s" \n \r' % (event.name, str(event)))
if 'DTMF' in event.name:
if d['channel2'] in event.keys['Channel']:
d['DTMF'] = event.keys['Digit']
d['log'] += str(event)
if not event.keys.get('Channel') is None:
if d['channel'] in event.keys['Channel']:
d['log'] += str(event)
if 'Dial' in event.name:
d['channel2'] = event.keys['Destination']
elif 'VarSet' in event.name:
if event.keys['Variable']=='DIALSTATUS':
d['status'] = event.keys['Value']
return None
#endregion
def kwargs_get(qs, key, default=None):
res, *junk = qs.get(key, (default,))
if default is None and res is default:
raise KeyError(key)
return res
make_call_auto - точка входа. Парсит входные параметры, устанавливает соединение с ами, подписывается на события, инициализурует действие "Originate" с "Application" "PlayBack" для воспроизведения текста "спасибо".
asterisk.ami - либа обертка над работой с сокетами ami https://pypi.org/project/asterisk-ami/
from common import api_func
def get():
reg_path_list = []
reg_path_list.append({'method':'GET', 'func':'/make_call_auto', 'handler':api_func.make_call_auto})
return reg_path_list
Регистрация доступных команд для вызова прокси сервера
AMI_SECRET = 'secret'
AMI_USER = 'usr'
AMI_PORT = 5038
AMI_ADDRESS = '192.168.10.19'
APP = 'PlayBack'
DATA = '/usr/local/share/asterisk/moh//auto/sps'
FILE_NAMES = {'kia':'file_name=/usr/local/share/asterisk/moh//auto/auto_kia',
'ford':'file_name=/usr/local/share/asterisk/moh//auto/auto_ford',
'renault':'file_name=/usr/local/share/asterisk/moh//auto/auto_renault'}
Константы доступ к ами, пути к файлам
Запускаем, проверяем
Шаг 4. 1С
Вопрос структуры хранения данных для обзвона в данной статье описывать не буду.
ТелоЗапроса = "tel="+tel+"&file="+file;
Сервер = "";
ПортСервера = "";
ТаймАут = 0;
Соединение = Новый HTTPСоединение(Сервер, ПортСервера,,,,ТаймАут);
Запрос = Новый HTTPЗапрос("/make_call_auto?"+ТелоЗапроса);
Попытка
Результат = Соединение.Получить(Запрос);
Исключение
КонецПопытки;
Сообщить(Результат.ПолучитьТелоКакСтроку());
Результат:
РезультатЗапроса = Запрос.Выполнить();
ВыборкаДетальныеЗаписи = РезультатЗапроса.Выбрать();
Пока ВыборкаДетальныеЗаписи.Следующий() Цикл
file = получить имя файла для бэка
Если file=Неопределено Тогда
Продолжить;
КонецЕсли;
ТелоЗапроса = "tel="+ВыборкаДетальныеЗаписи.Телефон+"&file="+file;
Сервер = ";
ПортСервера = "";
ТаймАут = 60*5;
Соединение = Новый HTTPСоединение(Сервер, ПортСервера,,,,ТаймАут);
Запрос = Новый HTTPЗапрос("/make_call_auto?"+ТелоЗапроса);
Попытка
Результат = Соединение.Получить(Запрос);
Исключение
Продолжить;
КонецПопытки;
СтрокаДжейсон = Результат.ПолучитьТелоКакСтроку();
Данные = JSON.лПрочитатьJSON(СтрокаДжейсон,,,Истина);
... логика обработки
Логи.ЗафиксироватьИнформацию("Автообзвон", СтрокаДжейсон);
КонецЦикла;
для тех у кого 7.7
...
ОбъектHTTP = СоздатьОбъект("WinHttp.WinHttpRequest.5.1");
ОбъектHTTP.Open("GET", ТелоЗапроса);
Рез = ОбъектHTTP.Send();
...
часть 2: //infostart.ru/public/1022878/