Недавно мне потребовалось добавить метрику по uptime сервиса дистанционного обслуживания для расчета SLA. Статистика по вызовам API является косвенным показателем работоспособности, а нужна достоверная проверка всех функций от дозвона из внешней сети, до прохождения пользователя по всему меню обслуживания. В интернете ничего готового не видел, поэтому решил поделиться своими изысканиями.


Есть система дистанционного обслуживания – клиент может позвонить в call-центр и проверить/изменить настройки своей учётной записи без участия оператора. Для перехода по меню и управления настройками используются тональные сигналы (DTMF). АТС в свою очередь взаимодействует с ядром основной системы через API, возвращая результаты пользователю в виде голосовых сообщений.


Задача: настроить автоматизированную проверку системы (правильно отвечает на запросы/выполняет нужные команды).
Главные требования:


  • максимальная правдоподобность имитации пользователя: т.е. нужно именно звонить и нажимать кнопки, а не вызывать методы API в обход call-центра.


  • работа именно с тем планом набора, в который попадают обычные пользователи; нельзя делать специальный контекст для автоматизированной проверки.

Для данной статьи упростим наш call-центр и API до безобразия: при звонке в call-центр пользователю доступна единственная услуга (клавиша 1); в ней пользователю предлагается ввести ПИН и в случае его корректности выдается статус учетной записи пользователя (ON/OFF); шаг влево или вправо – выдается сообщение об ошибке. Через API доступно три метода: GET ping (инициализация звонка), GET status (получить статус), POST status (установить статус).


Блок-схема IVR


Решать задачу будем с помощью Asterisk. По сути нам нужно собрать аналогичный IVR только от лица клиента: нужно описать машину состояний (ждем приветствие, ждем запроса ПИН и т.д.), и при переходе в каждое из состояний выполнять определённые действия.


Команды отправлять понятно как – call-центр ожидает тональные сигналы от пользователя – значит можно воспользоваться командой SendDTMF и «нажимать» нужные кнопки от лица клиента.


А как изменять своё состояние? Да точно так же! Для этого немного модернизируем dialplan нашего боевого IVR'а вызовом незамысловатого макроса в ключевых местах:


[macro-robot]
exten => s,1,ExecIf($["${CALLERID(name)}"!="Robot"]?MacroExit())
same => n,Wait(1)
same => n,SendDTMF(${ARG1})

В результате в ключевых местах работы IVR, если звонок поступил от робота, в канал будет отправляться выбранная нами DTMF последовательность. Задержка в 1 секунду добавлена, чтобы наш робот успевал перейти в режим ожидания ввода.


Теперь нам пригодится возможность Asterisk отправлять совершаемый звонок в нужный нам локальный контекст – таким образом замкнём между собой IVR call-центра и нашего робота. В самом простом варианте мы можем использовать call-файл и запускать проверку, периодически копируя этот файл в /var/spool/asterisk/outgoing/.


Процесс проверки у нас будет такой:


1. Звоним в call-центр


2. ждём, пока можно будет выбирать услугу


3. Нажимаем «1»


4. Ждём, пока можно будет вводить ПИН


5. Вводим ПИН


6. Узнаем состояние


— При первой проверке вызовом API меняем состояние на противоположное и заново проверяем состояние (переходим в п.3)


— При второй проверке убеждаемся, что состояние изменилось на противоположное


8. Если состояние изменилось, считаем проверку успешной


9. Во всех остальных случаях сообщаем об ошибке


Ниже я объединил «на одном экране» dialplan'ы обоих Asterisk'ов и показал, как передается управление/изменение состояний:


Схема взаимодействия


Текст, а не картинка

Спрятал в спойлер, т.к. рвёт экран.


                                            # Call-file
                                            Channel: SIP/cc_peer/ivr    >----------                                            Callerid: Robot                        |
                                     /--<   Context: robot-test                    |
                                     |      Extension: s                           |
                                     |                                             |
                                     |                                             |
[robot-test]                         |                                             |          [ivr]
                                     |                                             |
exten => s,1,NoOp(Wait for init)  <--/                                             \----->    exten => s,1,NoOp(IVR Start)
same => n,Set(STATUS=)                                                                        same => n,Answer()
same => n,WaitExten(10)                                                                       same => n,GotoIf($["${CURL(localhost/ping)}"!="PONG"]?err,1)
                                                                  /----------------------<    same => n,Macro(robot,00)
exten => 00,1,NoOp(Init done)     <------------------------------/                            same => n,Background(welcome)
same => n,Wait(1)                                                                             same => n(again),Background(press_1_for_status)
same => n,SendDTMF(1)    ; Press 1 for status   >----------------\                            same => n,WaitExten(5)
same => n,WaitExten(10)                                           \                           same => n,Goto(err,1)
                                                                   exten => 10,1,NoOp(Status check)  <---------------------------\     \-------------------->    exten => 1,1,NoOp(Status check)     <---------------------------------same => n,Wait(1)                                              \-------------------------<    same => n,Macro(robot,10)                                             |
same => n,SendDTMF(1234) ; Send pin code        >---------------------------------------->    same => n,Read(PIN,enter_pin,4)                                       |
same => n,WaitExten(10)                                                                       same => n,Set(STATUS=${CURL(localhost/status?pin=${PIN})})            |
                                                                                              same => n,GotoIf($["${STATUS}"=="ON"]?on)                             |
exten => _1[12],1,NoOp(Status)    <--------------------------------------------------\        same => n,GotoIf($["${STATUS}"=="OFF"]?off)                           |
same => n,ExecIf($[${EXTEN}==11]?MSet(CURRENT=ON,NEW=OFF))                           |        same => n,Goto(err,1)                                                 |
same => n,ExecIf($[${EXTEN}==12]?MSet(CURRENT=OFF,NEW=ON))                           |---<    same => n(on),Macro(robot,11)                                         |
same => n,GotoIf($["${STATUS}"==""]?toggle)                                          |        same => n,Background(status_on)                                       |
same => n,ExecIf($["${STATUS}"=="${CURRENT}"]?System(echo GOOD >> /cc_check.log))    |        same => n,Goto(s,again)                                               |
same => n,ExecIf($["${STATUS}"!="${CURRENT}"]?System(echo BAD >> /cc_check.log))     \---<    same => n(off),Macro(robot,12)                                        |
same => n,Hangup()                                                                            same => n,Background(status_off)                                      |
same => n(toggle),NoOp(Toggle status)                                                         same => n,Goto(s,again)                                               |
same => n,Set(STATUS=${NEW})                                           /--------------------------------------------------------------------------------------------/
same => n,Set(RES=${CURL(localhost/status,status=${NEW})})            /                       exten => err,1,NoOp(Error occured)
same => n,Wait(1)                                                    /            /------<    same => n,Macro(robot,99)
same => n,SendDTMF(1)   ; Press 1 for status   >--------------------/            /            same => n,Playback(error)
same => n,WaitExten(10)                                                         /             same => n,Hangup()
                                                                               /
exten => i,1,System(echo BAD >> /cc_check.log)    <---------------------------/               [macro-robot]
                                                                                              exten => s,1,ExecIf($["${CALLERID(name)}"!="Robot"]?MacroExit())
                                                                                              same => n,Wait(1)
                                                                                              same => n,SendDTMF(${ARG1})

В нашем примере результат выполнения проверки записывается в файл /cc_check.log. В боевой системе вы конечно же эти результаты будете складывать в свою систему мониторинга.


В реальной системе простого CURL-запроса к API для проверки всей системы дистанционного обслуживания скорее всего не хватит, поэтому решение можно расширить для контроля звонка робота через AMI. Для этого нужно модифицировать dialplan робота, чтобы он отправлял UserEvent'ы в AMI. Нашу демонстрационную конфигурацию можно изменить следующим образом:


robot-ami.conf
[robot-test-ami]
exten => s,1,UserEvent(CC_ROBOT_WAIT_INIT,RobotId: ${RobotId})
same => n,Set(STATUS=)
same => n,WaitExten(10)

exten => 00,1,UserEvent(CC_ROBOT_WAIT_SERVICE,RobotId: ${RobotId})
same => n,Wait(1)
same => n,UserEvent(CC_ROBOT_WAIT_SERVICE,RobotId: ${RobotId},Data: Will press 1 now)
same => n,SendDTMF(1)    ; Press 1 for status
same => n,WaitExten(10)

exten => 10,1,UserEvent(CC_ROBOT_WAIT_PIN,RobotId: ${RobotId})
same => n,Wait(1)
same => n,UserEvent(CC_ROBOT_WAIT_PIN,RobotId: ${RobotId},Data: Will send pin (1234) now)
same => n,SendDTMF(1234) ; Send pin code
same => n,WaitExten(10)

exten => _1[12],1,UserEvent(CC_ROBOT_STATUS_CHECK,RobotId: ${RobotId})
same => n,ExecIf($[${EXTEN}==11]?MSet(CURRENT=ON,NEW=OFF))
same => n,ExecIf($[${EXTEN}==12]?MSet(CURRENT=OFF,NEW=ON))
same => n,UserEvent(CC_ROBOT_STATUS_CHECK,RobotId: ${RobotId},Data: Current status is '${CURRENT}')
same => n,GotoIf($["${STATUS}"==""]?toggle)
same => n,ExecIf($["${STATUS}"=="${CURRENT}"]?UserEvent(CC_ROBOT_RESULT,RobotId: ${RobotId},Data: GOOD))
same => n,ExecIf($["${STATUS}"!="${CURRENT}"]?UserEvent(CC_ROBOT_RESULT,RobotId: ${RobotId},Data: BAD))
same => n,Hangup()
same => n(toggle),UserEvent(CC_ROBOT_STATUS_CHECK,RobotId: ${RobotId},Data: Need to toggle state)
same => n,Set(STATUS=${NEW})
same => n,UserEvent(CC_ROBOT_TOGGLE,RobotId: ${RobotId},Data: ${CURRENT})
same => n,Wait(2)
same => n,SendDTMF(1)
same => n,WaitExten(10)

exten => i,1,UserEvent(CC_ROBOT_RESULT,RobotId: ${RobotId},Data: BAD)

Для взаимодействия с таким dialplan'ом необходимо подключиться к Asterisk'у, с которого инициируется звонок робота, через AMI, сделать Originate и дальше действовать в соответствии с приходящими UserEvent'ами. Пример реализации скрипта нашей demo-проверки на Python:


test_call.py
#!/usr/bin/python

import os
import time
import string
import random
import sys
import requests

from asterisk.ami import Action, AMIClient

seconds_to_wait = 30
test_result = 'unknown'
host = 'localhost' # Asterisk with AMI and test dialplan
user = 'robot' # AMI user
password = 'MrRobot' # AMI password
call_to = 'Local/ivr' # Call-center
context = { # Robot dialplan context
    "context": "robot-test-ami",
    "extension": "s",
    "priority": 1
}

def toggle_state(new_state):
    print 'Will try to toggle state to {}'.format(new_state)
    r = requests.post('http://localhost/status', data = {'status':new_state.upper()})
    print 'Done! Actual state now: {}'.format(r.text)

def event_notification(source, event):
    global test_result
    keys = event.keys
    if 'RobotId' in keys:
        if keys['RobotId'] == robot_id: # it's our RobotId

            if 'Data' in keys:
                data = keys['Data']
            else:
                data = 'unknown'
            if 'UserEvent' in keys:
                name = keys['UserEvent']
            else:
                name = 'unknown'

            if name.startswith('CC_ROBOT'):
                print '{}: {}'.format(name, data)

            if name == 'CC_ROBOT_TOGGLE':
                if data.lower() in ['on','off']:
                    if data.lower() == 'on':
                        toggle_state('off')
                    else:
                        toggle_state('on')
                else:
                    print 'Unknown state {}'.format(data)

            if name == 'CC_ROBOT_RESULT':
                test_result = data

# Generate uniq RobotId to distinguish events from different robots
robot_id = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(8))
print 'Current RobotId: {}'.format(robot_id)

# Action to enable user events
aEnableEvents  = Action('Events', keys={'EventMask':'user'})

# Action to originate call
aOriginateCall = Action('Originate',
                        keys={'Channel':call_to, 'Context':context['context'], 'Exten':context['extension'], 'Priority':context['priority'], 'CallerId':'Robot'},
                        variables={'RobotId':robot_id}
                       )

# Init AMI client and try to login
client = AMIClient(host)

# Register our event listener
client.add_event_listener(event_notification)

try:
    future = client.login(user, password)
    # This will wait for 1 second or fail
    if future.response.is_error():
        raise Exception(str(future.response.keys['Message']))
except Exception as err:
    client.logoff()
    sys.exit('Error: {}'.format(err.message))

print 'Spawned AMI session to: {}'.format(host)

try:
    # Try to enable user events coming
    future = client.send_action(aEnableEvents,None)
    if future.response.is_error():
        raise Exception(str(future.response.keys['Message']))
    print 'Logged in as {}'.format(user)

    # Try to originate call
    future = client.send_action(aOriginateCall,None)
    if future.response.is_error():
        raise Exception(str(future.response.keys['Message']))
    print 'Originated test call'

except Exception as err:
    client.logoff()
    sys.exit('Error: {}'.format(err.message))

print 'Waiting for events...'

# Wait for events during timelimit interval
for i in range(seconds_to_wait):
    time.sleep(1)
    # If test_result is changed (via events), then stop waiting
    if test_result != 'unknown':
        break;
else:
    client.logoff()
    sys.exit('Error: time limit exceeded')

# Logoff if we still here
client.logoff()

print 'Test result: {}'.format(test_result)

Действия выполняются аналогичные описанному выше сценарию с call-файлом, но звонок инициируется по команде скрипта и вызов API тоже осуществляется из скрипта при получении события CC_ROBOT_TOGGLE. Результат проверки приходит вместе с событием CC_ROBOT_RESULT.


В итоге мы решили задачу в рамках требуемых условий: звоним и «нажимаем кнопки», делаем проверки в боевом dialplan'е (выдавая тоны по ходу звонка, если звонит робот).


Удачного тестирования!

Поделиться с друзьями
-->

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


  1. las68
    05.09.2016 04:42

    Интересно решили задачу, молодцы!


  1. antirek
    05.09.2016 11:05

    Тестировать более логично программный код, поэтому удобнее использовать диалплан на lua.
    Если вы будете развивать систему долгие годы, то посмотрите в сторону lua: разделение на компоненты диалплана, тесты каждого компонента и т.д. Т.е. всю логику можно тестировать и без реального астериска. Это не исключает тестирования «боевого поведения пользователя», но отделит тестирование логики от тестирования боевого поведения и сделает второе более простым.
    У вашего подхода есть хороший плюс — он сразу проверит прохождение тональных сигналов, но и это можно сделать отдельным тестом по вашему подобию и тоже на lua; )

    P.S. Впервые вижу, что кто-то так заморочился тестированием ivr, это впечатляет.


  1. ssh24
    05.09.2016 11:12
    +1

    Только немного смущает наличие тестового кода в продакшен-сценарии.
    А вообще идея интересная. Нашему начальству бы понравилась))


  1. aylarov
    05.09.2016 11:42

    Можно для этих же целей использовать voximplant