Наверняка у многих из вас, как и у меня, была идея сделать что-нибудь уникальное. В этой статье я опишу технические проблемы и решения, с которыми пришлось столкнуться при разработке АТС. Возможно, это кому-то поможет решиться на свою идею, а кому-то пройти по протоптанной дорожке, ведь я тоже пользовался опытом первопроходцев.
Идея и ключевые требования
А началось всё банально с любви к Asterisk (framework для построения коммуникационный приложений), автоматизации телефонии и установок FreePBX (веб интерфейс для Asterisk). Если потребности компании были без особенностей и укладывались в возможности FreePBX – всё супер. Вся установка проходила за сутки, компания получала настроенную АТС, удобный интерфейс и краткое обучение плюс сопровождение по желанию.
Но самые интересные задачи были нестандартными и тогда было не так сказочно. Asterisk может многое, но чтобы сохранить в рабочем виде веб-интерфейс, приходилось потратить в разы больше времени. Так небольшая мелочь могла занять времени гораздо больше, чем установка всей остальной АТС. И дело не в том, что писать веб интерфейс долго, а скорее дело в особенностях архитектуры FreePBX. Подходы и методы архитектуры FreePBX закладывалась во времена php4, а в тот момент уже был php5.6 на котором всё можно было сделать проще и удобнее.
Последней каплей стали графические диалпланы в виде схемы. Когда попытался подобное построить для FreePBX, понял, что придётся существенно его переписать и проще уже построить что-нибудь новое.
Ключевыми требованиями стали:
- простая настройка, интуитивно доступная даже начинающему администратору. Тем самым компаниям не требуется обслуживание АТС на нашей стороне,
- легкая доработка, чтобы задачи решались за адекватное время,
- удобство интеграции с АТС. У FreePBX не было API для изменения настроек, т.е. нельзя, например, создавать группы или голосовые меню из стороннего приложения, только API самого Asterisk,
- opensource – для программистов это крайне важно для доработок под клиента.
Идея более быстрой разработки была в том, чтобы весь функционал состоял из модулей в виде объектов. Все объекты должны были иметь общий родительский класс, а значит названия всех основных функций уже известны и значит уже есть реализации по умолчанию. Объекты позволят резко сократить количество аргументов в виде ассоциативных массивов со строковыми ключами, узнать которые в FreePBX можно было, исследовав всю функцию и вложенные функции. В случае объектов банальное автодополнение покажет все свойства, да и целом во много раз упростит жизнь. Плюс наследование и переопределение уже закрывает множество проблем с доработками.
Следующее, что замедляло время доработки и чего стоило избежать — это дублирование. Если есть модуль ответственный за дозвон до сотрудника, то все остальные модули, которым нужно отправить звонок сотруднику, должны использовать именно его, а не создавать свои собственные копии. Так, если нужно что-нибудь поменять, то менять придется только в одном месте и поиск «как это работает» проводить одного места, а не осуществлять поиск по всему проекту.
Первая версия и первые ошибки
Первый прототип был готов уже через год. Вся АТС, как и планировалось, была модульная, и модули могли не только добавлять новый функционал для обработки звонков, но и менять сам веб-интерфейс.
Да, идея построения диалплана в виде такой схемы не моя, но она весьма удобная и я сделал тоже самое для Asterisk.
С помощью написания модуля, программисты уже могли:
- создавать для обработки звонка собственный функционал, который можно было разместить на схеме, а также в меню элементов слева,
- создавать собственные страницы для веб-интерфейса и добавлять свои шаблоны на существующие страницы (если разработчик страницы это предусмотрел),
- добавлять свои настройки на вкладку основных настроек или создавать собственную вкладку с настройками,
- программист может пронаследоваться от существующего модуля, изменить часть функционала и зарегистрировать под новым именем или заменить оригинальный модуль.
Например, вот так можно создать своё голосовое меню:
......
class CPBX_MYIVR extends CPBX_IVR
{
function __construct()
{
parent::__construct();
$this->_module = "myivr";
}
}
.....
$myIvrModule = new CPBX_MYIVR();
CPBXEngine::getInstance()->registerModule($myIvrModule,__DIR__); //Зарегистрировать новый модуль
CPBXEngine::getInstance()->registerModuleExtension($myIvrModule,'ivr',__DIR__); //Подменить существующий модуль
Первые сложные внедрения принесли первую гордость и первые разочарования. Радовало то, что оно работало, что я уже смог воспроизвести основные возможности FreePBX. Радовало, что людям идея схемы пришлась по душе. Было ещё много вариантов упростить разработку, но и на тот момент часть задач уже делалась проще.
Разочарованием стало API для изменения конфигурации АТС — получилось совсем не то, что хотелось. Я взял тот же принцип, что и во FreePBX, по нажатию кнопки Apply пересоздается вся конфигурация и перезапускаются модули.
Выглядит это так:
*Диалплан — правило(алгоритм), по которому обрабатывается звонок.
Но при таком варианте невозможно написать нормальное API для изменения настроек АТС. Во-первых, операция применения изменений к Asterisk слишком долгая и ресурсоемкая.
Во-вторых, нельзя вызвать две функции одновременно, т.к. обе будут создавать конфигурацию.
В-третьих, применяет все настройки в том числе сделанные администратором.
В этой версии, как и в Askozia, можно было генерить конфигурацию только измененных модулей и перезапускать только необходимые модули, но это всё полумеры. Необходимо было менять подход.
Вторая версия. Нос вытащил хвост увяз
Идеей для решения проблемы стало не пересоздавать конфигурацию и диалплан для Asterisk, а сохранять информацию в базу и читать из базы прямо во время обработки звонка. Asterisk уже умел читать конфигурации из базы, достаточно поменять значение в базе и следующий звонок уже будет обрабатываться с учетом изменений, а для чтения параметров диалплана отлично подошла функция REALTIME_HASH.
В итоге не понадобилось даже перезапускать Asterisk при изменении настроек и все настройки стали применяться сразу к Asterisk.
Единственные изменения диалплана – это добавления внутренних номеров и hints. Но это были маленькие точечные изменения
exten=>101,1,GoSub(‘sub-callusers’,s,1(1)); - точечное изменение, добавляется/изменяется через ami
; sub-callusers – универсальная функция генерится при установке модуля.
[sub-callusers]
exten =>s,1,Noop()
exten =>s,n,Set(LOCAL(TOUSERID)=${ARG1})
exten =>s,n,ClearHash(TOUSERPARAM)
exten =>s,n,Set(HASH(TOUSERPARAM)=${REALTIME_HASH(rl_users,id,${LOCAL(TOUSERID)})})
exten =>s,n,GotoIf($["${HASH(TOUSERPARAM,id)}"=""]?return)
...
Добавить или поменять строку в диалплане легко можно через Ami (интерфейс управления Asterisk) и перезагрузки всего диалплана не требуется.
Так была решена проблема с API для конфигурации. Можно было даже напрямую зайти в базу и добавить новую группу или поменять, например, время дозвона в поле “dialtime” у группы и следующий звонок уже будет длиться указанное время (Это не рекомендация к действию, т.к. для некоторых API операций требуются Ami вызовы).
Первые сложные внедрения опять принесли первую гордость и разочарование. Радовало то, что это работает. База данных стала критически важным звеном, выросла зависимость от диска, рисков больше, но всё работало стабильно и без проблем. А главное теперь всё, что можно было сделать через веб-интерфейс, можно было сделать и через API и при этом использовались одни и те же методы. Дополнительно, веб-интерфейс избавился от кнопки «применить настройки к АТС», о которой администраторы часто забывали.
Разочарованием стало усложнение разработки. Ещё с первой версии язык php генерит диалплан на языке Asterisk и выглядит это совершенно нечитаемо, плюс сам язык Asterisk для написания диалплана крайне примитивен.
Как это выглядело:
$usersInitSection = $dialplan->createExtSection('usersinit-sub','s');
$usersInitSection
->add('',new \Dialplan\ext_gotoif('$["${G_USERINIT}"="1"]','exit'))
->add('',new \Dialplan\ext_set('G_USERINIT','1'))
->add('',new \Dialplan\ext_gosub('1','s','sub-AddOnAnswerSub','usersconnected-sub'))
->add('',new \Dialplan\ext_gosub('1','s','sub-AddOnPredoDialSub','usersinitondial-sub'))
->add('',new \Dialplan\ext_set('LOCAL(TECH)','${CUT(CHANNEL(name),/,1)}'))
->add('',new \Dialplan\ext_gotoif('$["${LOCAL(TECH)}"="SIP"]','sipdev'))
->add('',new \Dialplan\ext_gotoif('$["${LOCAL(TECH)}"="PJSIP"]','pjsipdev'))
Во второй версии диалплан стал универсальным, в него были заложены все возможные варианты обработки в зависимости от параметров и его размер значительно вырос. Всё это сильно замедляло время разработки, и сама мысль что в очередной раз нужно вмешиваться в диалплан наводила грусть.
Третья версия
Идеей для решения проблемы стало не генерить Asterisk диалплан из php, а использовать FastAGI и все правила обработки писать уже на самом php. FastAGI позволяет Asterisk, для обработки звонка, подключиться к сокету. Получать оттуда команды и отправлять результаты. Таким образом логика диалплана находится уже за границами Asterisk и может быть написана на любом языке, в моём случае на php.
Тут было много проб и ошибок. Главной проблемой являлось, что у меня уже было много классов/файлов. На создание объектов, инициализацию и взаимную регистрацию между ними уходило около 1,5 секунд, и эта задержка на каждый звонок не то, что можно игнорировать.
Инициализация должна была быть только 1 раз и поэтому поиски решения начались с написания сервиса на php с использованием Pthreads. Спустя неделю экспериментов этот вариант был отложен из-за тонкостей работы этого расширения. От асинхронного программирования на php после месяца тестов тоже пришлось отказаться, нужно было что-то простое, знакомое любому новичку php, да и многие расширения для php синхронные.
Решением стал свой многопоточный сервис на ‘си’, который компилировался с PHPLIB. Он подгружает все php файлы АТС, ждёт, когда все модули инициализируются, добавят коллбэк друг к другу и когда всё готово – кэширует. При запросе по FastAGI создаётся поток, в нём воспроизводится копия из кэша всех классов и данных и запрос передается в php функцию.
При таком решении время от отправки звонка в наш сервис до первой команды Asterisk сократилось с 1,5с до 0,05с и это время слабо зависит от размера проекта.
В итоге, время на разработку диалплана сократилось существенно, и я могу это оценить поскольку мне пришлось переписать веcь диалплан всех модулей на php. Во-первых, в php уже должны быть написаны методы для получения объекта из базы, они были нужны для отображения в веб-интерфейсе, а во-вторых, и это главное – наконец-то появилась возможность удобной работы со строками с числами с массивами с базой данных плюс множество расширений php.
Для обработки диалплана в классе модуля нужно реализовать функцию dialplanDynamicCall и аргумент pbxCallRequest будет содержать объект для взаимодействия с Asterisk.
В дополнении появилась возможность отлаживать диалплан (в php есть xdebug и для нашего сервиса оно работает), можно двигаться по шагам просматривая значения переменных.
Данные по звонкам
Для любой аналитики и отчётов нужны правильно собранные данные и этот блок АТС тоже проходил много проб и ошибок с первой по третью версию. Зачастую данные по звонкам – это табличка. Один звонок = одна запись: кто звонил, кто ответил, сколько проговорили. В более интересных вариантах есть ещё дополнительная табличка, кого из сотрудников АТС вызывала во время звонка. Но всё это закрывает лишь часть потребностей.
Первоначальными требованиями стали:
- сохранять не только кому звонила АТС, но и кто ответил, т.к. существуют перехваты и при анализе звонков это нужно будет учитывать,
- время до соединения с сотрудником. Во FreePBX и некоторых других АТС, звонок считается отвеченным, как только АТС поднимет трубку. Но для голосового меню уже нужно поднять трубку, таким образом все звонки становятся отвеченными и время ожидания ответа становится 0-1 секунду. Поэтому решено было сохранять не только время до ответа, но время до соединения с ключевыми модулями (модуль сам устанавливает у себя это флаг. Сейчас это «Сотрудник», «Внешняя линия»),
- для более сложного диалплана, когда звонок гуляет между разными группами, нужна была возможность каждый элемент исследовать по отдельности.
Лучшим вариантом оказался вариант, когда модули АТС сами о себе отправляют информацию по звонкам и в итоге сохранять информацию в виде дерева.
Выглядит это следующим образом:
Для начала общая информация о звонке(как у всех — ничего особенного).
- Поступил звонок по внешней линии «Для теста» в 05:55:52 с номера 89295671458 на номер 89999999999, в итоге на него ответил сотрудник «Секретарь2» с номером 104. Клиент прождал 60 секунд и разговаривал 36 секунд.
- Сотрудник «Секретарь2» делает звонок на номер 112 и на него отвечает сотрудник «Менеджер1» спустя 8 секунд. Разговаривают 14 секунд.
- Клиента переводят на Сотрудника «менеджер1» где они продолжают разговаривать ещё 13 секунд
Но это вершина айсберга, по каждой записи можно получить подробное прохождение звонка по АТС.
Вся информация представляется в виде вложенности вызовов:
- Поступил звонок по внешней линии «Для теста» в 05:55:52 с номера 89295671458 на номер 89999999999.
- В 05:55:53 внешняя линия отправляет звонок на Входящую схему «test»
- Во время обработки звонка по схеме вызывается модуль «вызов менеджера», в котором звонок находится 16 секунд. Это разработанный под клиента модуль.
- Модуль «вызов менеджера» отправляет звонок на ответственного за номер (клиента) сотрудника «Менеджер1» и ожидает ответа 5 секунд. Менеджер не ответил.
- Модуль «вызов менеджера» отправляет звонок на группу «Менеджеры КОРП». Это другие менеджеры такого же направления (сидят в одной комнате) и ожидает ответа 11 секунд.
- Группа «Менеджеры КОРП» вызывает сотрудников «Менеджер1, Менеджер2, Менеджер3» одновременно по 11 секунд. Ответа нет.
- Вызов менеджера завершается. И схема звонок отправляет на модуль «Выбор маршрута из 1с». Тоже написанный под клиента модуль. Тут звонок обрабатывался 0 секунд.
- Схема отправляет звонок на голосовое меню «Осн с донабором». Клиент в нём прождал 31 секунду, донабора не было.
- Схема отправляет звонок на Группу «Секретари», где клиент прождал 12 секунд.
- В группе вызывается одновременно 2 сотрудника «Секретарь1» и «Секретарь2» и спустя 12 секунд отвечает сотрудник «Секретарь2». Ответ на вызов дублируется в родительские вызовы. Получается и в группе ответил «Секретарь2», при вызове схемы ответил «Секретарь2» и на звонок по внешней линии ответил «Секретарь2».
Именно сохранение информации о каждой операции и их вложенности позволит просто сделать отчёты. Отчёт по голосовому меню поможет выяснить, насколько оно помогает или мешает. Построить отчёт о пропущенных сотрудниками звонках с учётом, что звонок перехватили и значит не считается пропущенным, и с учётом, что это был групповой звонок, и кто-нибудь другой взял раньше, а значит тоже звонок не пропущенный.
Такое хранение информации позволит взять каждую группу в отдельности и определить насколько она эффективно работает, построить график отвеченных и пропущенных группы по часам. Также можно проверить, насколько угадывает соединение с ответственным менеджером, анализируя переводы после соединения с менеджером.
В том числе можно проводить достаточно нетипичные исследования, например, как часто номера, которых нет в базе, набирают правильный добавочный или какой процент исходящих звонков является переадресацией на мобильный.
Что в итоге?
Для обслуживания АТС не требуется специалист, с этим справляется самый обычный администратор – проверено на практике.
Для доработок не нужны специалисты с серьёзной квалификацией достаточно знаний php, т.к. уже написаны модули и для sip протокола, и для очереди, и для вызова сотрудника и другие. Есть класс обёртка для Asterisk. Программист для разработки модуля может (и по-хорошему должен) вызывать уже готовые модули. И знания Asterisk совершенно не нужны, если клиент просит добавить страницу с каким-нибудь новым отчётом. Но практика показывает, что сторонние программисты хоть и справляются, но без документации и нормального покрытия комментариями чувствуют себя неуверенно, поэтому ещё есть куда двигаться.
Модули могут:
- создавать новые возможности по обработке звонка,
- добавлять новые блоки в веб-интерфейс,
- пронаследоваться от любого из существующих модулей, переопределить функции и подменить его или просто быть слегка изменённой копией,
- добавлять свои настройки в шаблон настроек других модулей и многое другое.
Настройки АТС через API. Как описано выше, все настройки хранятся в базе и читаются в момент вызова, поэтому через API можно менять все настройки АТС. При вызове API не пересоздаётся конфигурация и не перезапускаются модули, следовательно, не важно насколько много у вас настроек и сотрудников. API запросы выполняются быстро и не блокируют друг друга.
АТС сохраняет все ключевые операции со звонками с длительностями (ожидания/разговора), вложенностями и в терминах АТС (сотрудник, группа, внешняя линия, а не канал, номер). Это позволяет строить различные отчёты под конкретных клиентов и большая часть работы – сделать удобный интерфейс.
Что будет дальше покажет время. Есть ещё много нюансов, которые стоит переделать, есть ещё много планов, но от создания 3-ей версии прошёл уже год и уже можно сказать, что идея работает. Основной минус 3-й версии – это аппаратные ресурсы, но за удобство разработки обычно всегда именно так и приходится платить.
x893
Лет 8 назад сделал sourceforge.net/projects/asterisk-dotnet и потом какое-то время использовал для ASP.NET и десктопных .NET приложений.
Сильно удобнее чем с PHP.
mrkasper Автор
Я на самом деле тоже присматривался к mono и да, если бы изначально писал fastAGI сервис то на нём бы и остановился.
Но на тот момент fastAGI и в планах не было, а в главных всё таки хотелось проект который компании могли бы дорабатывать под себя.
Есть же отечественная АТС к который модули пишутся на C# и не так то просто найти грамотного специалиста который её доработает. С php всё прощё — «любой школьник» знает php, пусть и уровень таких «школьников» не очень, но если им написать документацию и сделать всё удобно… По крайней мере я делаю ставку на это =)
x893
C# как то мне ближе.
А под mono использовали ребята из Вьетнама. Но года 3-4 назад.
Один раз пришлось * родной компонент для queue переделать.
C# он всё равно ближе к С чем к php.
mrkasper Автор
Одним знакомым программистом под asterisk больше =) ( Asterisk .NET library достаточно примелькавшаяся библиотека)
И кстати, если углубляться в детали, в моей сборке queue тоже переписан. Добавлено дополнительное поле в логи, но главное переделан ringall.
Оригинальный queue ringall не учитывает что оператор освободился, кто свободен вызывается, кто занят присоединится только на следующем цикле.
В моём варианте ringall как только оператор положит трубку он сразу присоединяется к текущему циклу и это даже повышает скорость обработки.
По словам клиента с которым дорабатывали % на 20, но возможно преувеличивает, точные замеры я сделать не смог.
x893
Ну я уже детали не помню — лет 10 прошло.