1С + asterisk (автоматический обзвон) часть 1




Пример реализации автообзвона (с обработкой ответа на отвечающей стороне) с использованием ami asterisk. Данная статья может быть полезна программистам, интеграторам, администраторам.
Версия и релиз технологической платформы не имеет значения.

Пример реализации автообзвона (с обработкой ответа (нажатия) отвечающей стороны) с использованием 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.

Сам алгоритм обработки звонка статичен. Для реализации задачи был добавлен контекст и макрос.

 

 extension.conf

[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.conf

 [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. прокси сервер

стартер:

 

 start.py

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()


 

обработчик запросов: 

 

 ami_client.py


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.

обработчик команд, ами клиент:

 

 api_func.py


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" 
 
' % (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/

 

 path_list.py

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

 

Регистрация доступных команд для вызова прокси сервера

 

 conf.py

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С

Вопрос структуры хранения данных для обзвона в данной статье описывать не буду. 

 

 click to call

 ТелоЗапроса = "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/

9 Comments

  1. Tiger77

    Спасибо за статью, очень познавательно.

    А почему выбран Pyton для работы бек-офиса, а не компонента 1с ?

    Reply
  2. extrim-style

    Когда-то давным давно (много лет назад) я ковырялся с астериск’ом, выполняя сведение найденных в его базе семплов, чтобы получить необходимую озвучку. Сегодня я узнал, что там есть процедура генерации… Как говорится — «лучше поздно…»).

    (а может тогда ещё и не было?..)

    Reply
  3. dmarenin

    (2) Смысл статьи не в процедуре генерации, а точнее то что есть но не сравнится с яндекс спич. Процедура «плейлоад» — воспроизведение файлов была там как раз давно. Суть данной статьи описать взаимодействие стеков и показать как можно обработать, создать «исходящий» ивр.

    Reply
  4. extrim-style

    (4) да, я понял, всё-таки яндекс.спич… (прочитал невнимательно)

    Reply
  5. dmarenin

    (1) я художник, я так вижу.

    А по делу если, то:

    1) в организации используется не только 1с(не одинэсом едины), есть приложения(не 1с) которые могут вызвать этот метод

    2) компонента 1с сможет ли подписаться на события? Будет работать лучше? Быстрее?

    3) код открыт

    4) архитектура микросервисов(возможно, напишу статью про использование апач кафка(шина данных))

    5) под каждую задачу должен быть использован свой яп(стэк, технологии, и тд.)(1с в случае использования вк служит лишь средой исполнения(запуска с передачей управления в другой процесс(ну ладно если там треды, тогда контекст не остановится)) кода, и возможно пост обработкой результата, таким образом можно было бы сделать и на крестах и на си и на си шарпе и на дж и на 1с и на тд…)

    6) «кроссплатформенность»

    7) личные предпочтения

    8) используется отдельный поток исполнения, можно, доработав, и не ждать возврата управления в 1с, а сделать асинхронно либо событийно

    9) ну и тд …

    Reply
  6. ArchLord42

    Довольно странная реализация половина через диалплан, половина через ами, раз уж разбирались с данной технологией неужеди agi прошёл мимо вас?

    А вообще есть симбиоз этих двух технологий (ami/agi) это ari, примеры на том же офф репо на гитхаье есть, он бы значительно упростил поддержку и деплой приложения.

    Reply
  7. dmarenin

    (6) agi, ari не прошел мимо. ari не во всех версиях есть(в моем продакшене нет его).

    agi — тот же диалплан только управляемый(gateway interface), и должен быть собран до момента звонка.

    у меня задача стояла исходящего звонка, а не входящего, и как через аги реализовать то что сделано в диалплане(при подъеме отработать логику, прочитать, получить и тд..)??

    Reply
  8. ArchLord42

    (7) довольно просто, у меня у самого автообзвон через amiari реализован, исходящий звонок моодно направить в контекст а уже в диалпане прописать контекст и agi(xxx), там 3 строчки получается в итоге.

    agi — тот же диалплан только управляемый(gateway interface), и должен быть собран до момента звонка

    Непонятно что вы имеете ввиду под «собран»

    А так и ari и agi повторяют диалплан полностью собственно они и созданы для того чтобы управлять им из программ)

    Reply
  9. user1205069

    что за common в python скрипте

    Reply

Leave a Comment

Ваш адрес email не будет опубликован. Обязательные поля помечены *