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

Программирование - Практика программирования

asterisk автообзвон ami python

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

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

 

 

30

См. также

Специальные предложения

Комментарии
Избранное Подписка Сортировка: Древо
1. Tiger77 65 30.11.18 18:02 Сейчас в теме
Спасибо за статью, очень познавательно.

А почему выбран Pyton для работы бек-офиса, а не компонента 1с ?
5. dmarenin 76 30.11.18 19:58 Сейчас в теме
(1) я художник, я так вижу.
А по делу если, то:
1) в организации используется не только 1с(не одинэсом едины), есть приложения(не 1с) которые могут вызвать этот метод
2) компонента 1с сможет ли подписаться на события? Будет работать лучше? Быстрее?
3) код открыт
4) архитектура микросервисов(возможно, напишу статью про использование апач кафка(шина данных))
5) под каждую задачу должен быть использован свой яп(стэк, технологии, и тд.)(1с в случае использования вк служит лишь средой исполнения(запуска с передачей управления в другой процесс(ну ладно если там треды, тогда контекст не остановится)) кода, и возможно пост обработкой результата, таким образом можно было бы сделать и на крестах и на си и на си шарпе и на дж и на 1с и на тд...)
6) "кроссплатформенность"
7) личные предпочтения
8) используется отдельный поток исполнения, можно, доработав, и не ждать возврата управления в 1с, а сделать асинхронно либо событийно
9) ну и тд ...
2. extrim-style 7 30.11.18 18:55 Сейчас в теме
Когда-то давным давно (много лет назад) я ковырялся с астериск'ом, выполняя сведение найденных в его базе семплов, чтобы получить необходимую озвучку. Сегодня я узнал, что там есть процедура генерации... Как говорится - "лучше поздно...").
(а может тогда ещё и не было?..)
3. dmarenin 76 30.11.18 19:40 Сейчас в теме
(2) Смысл статьи не в процедуре генерации, а точнее то что есть но не сравнится с яндекс спич. Процедура "плейлоад" - воспроизведение файлов была там как раз давно. Суть данной статьи описать взаимодействие стеков и показать как можно обработать, создать "исходящий" ивр.
4. extrim-style 7 30.11.18 19:56 Сейчас в теме
(4) да, я понял, всё-таки яндекс.спич... (прочитал невнимательно)
6. ArchLord42 62 02.12.18 17:00 Сейчас в теме
Довольно странная реализация половина через диалплан, половина через ами, раз уж разбирались с данной технологией неужеди agi прошёл мимо вас?
А вообще есть симбиоз этих двух технологий (ami/agi) это ari, примеры на том же офф репо на гитхаье есть, он бы значительно упростил поддержку и деплой приложения.
7. dmarenin 76 02.12.18 17:14 Сейчас в теме
(6) agi, ari не прошел мимо. ari не во всех версиях есть(в моем продакшене нет его).
agi - тот же диалплан только управляемый(gateway interface), и должен быть собран до момента звонка.
у меня задача стояла исходящего звонка, а не входящего, и как через аги реализовать то что сделано в диалплане(при подъеме отработать логику, прочитать, получить и тд..)??
8. ArchLord42 62 02.12.18 20:54 Сейчас в теме
(7) довольно просто, у меня у самого автообзвон через ami\ari реализован, исходящий звонок моодно направить в контекст а уже в диалпане прописать контекст и agi(xxx), там 3 строчки получается в итоге.
agi - тот же диалплан только управляемый(gateway interface), и должен быть собран до момента звонка

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