В этой статье хочу рассказать, как мы решили не типовую задачу на FreePBX. Под определением «не типовую» я имею в виду, что ее нельзя решить стандартными средствами, без дополнительных инструментов.

Предыстория


Есть группа сотрудников, которая занимается обзвоном клиентов. Дабы экономить на исходящих звонках, для разных направлений используются разные номера телефонов. Это спокойно решается с помощью шаблонов (масок) номеров в Outbound Routes. Но часть направлений, например, звонки на мобильные, остается платным. Чтобы в конце месяца счет компании за телефонные услуги не перевалил XXX$, необходимо жестко контролировать и, при необходимости, ограничивать соответствующие направления звонков.

Задача


Установить индивидуальный дневной лимит для группы менедежеров. Запретить исходящие звонки на определенные направления при исчерпании лимита. При достижении пороговых значение: >50%, >90% и >100% отправлять соответствующее уведомление на email сотрудника. Если сотрудник в течении дня полностью не исчерпал свой дневной лимит, остаток должен перейти на следующий день.

Приступаем к выполнению


Для начала нужно определить на какие номера мы ограничиваем дозвон. В нашем случае это мобильные операторы Казахстана. Находим соответствующую статью в Википедии и стараемся сформировать шаблоны (маски) номеров. Так как FreePBX не имеет возможности использовать полноценные регулярные выражения, 23 возможных префикса нам удалось упаковать в 3 шаблона:
  • 870[5780-2]XXXXXXX
  • 877[15-8]XXXXXXX
  • 8747XXXXXXX

Создаем соответствующие записи в Outbound Routes. На данном примере открываем направления для внутреннего номера 2055:



Делаем это для того, чтобы соответствующие правила создались в конфигурационном файле:
/etc/asterisk/extensions_additional.conf 

Так как при редактировании и применении настроек во FreePBX, система каждый раз переписывает конфигурационные файлы, мы находим нужные нам блоки и перемещаем в файл:
/etc/asterisk/extensions_custom.conf
в который FreePBX не лезет.

Блок следующего вида:

Outbound Routes
exten => _877[15-8]XXXXXXX,1,Macro(user-callerid,LIMIT,EXTERNAL,)
exten => _877[15-8]XXXXXXX/2055,1,Macro(user-callerid,LIMIT,EXTERNAL,)
exten => _877[15-8]XXXXXXX/2055,n,ExecIf($[ "${CALLEE_ACCOUNCODE}" != "" ] ?Set(CDR(accountcode)=${CALLEE_ACCOUNCODE}))
exten => _877[15-8]XXXXXXX/2055,n,Set(MOHCLASS=${IF($["${MOHCLASS}"=""]?default:${MOHCLASS})})
exten => _877[15-8]XXXXXXX/2055,n,ExecIf($["${KEEPCID}"!="TRUE" & ${LEN(${TRUNKCIDOVERRIDE})}=0]?Set(TRUNKCIDOVERRIDE=<7123456789>))
exten => _877[15-8]XXXXXXX/2055,n,Set(_NODEST=)
exten => _877[15-8]XXXXXXX/2055,n,Gosub(sub-record-check,s,1(out,${EXTEN},))
exten => _877[15-8]XXXXXXX/2055,n,Macro(dialout-trunk,10,${EXTEN},,off)
exten => _877[15-8]XXXXXXX/2055,n,Macro(outisbusy,)


Если вы дружите с синтаксисом конфигов Asterisk'а, можно пропустить два предыдущих шага и сформировать нужные вам блоки самостоятельно.

Теперь можно удалить созданные ранее Outbound Routes, нужные нам разрешающие правила теперь содержатся в extensions_custom.conf. Таким образом, мы разрешили сотрудникам звонить по этим направлениям. Дальше больше.

Так как лимит является индивидуальным, и нам нужно рассылать уведомления на почту, необходимо где-то хранить всю эту информацию. Лучшим выбором будет использование базы данных. Тут у нас было два варианта:
  • использовать существующую базу данных asterisk, и добавить необходимые нам поля в таблицу users;
  • создать свою базу данных с таблицами нужной структуры.

Выбор пал на вариант№2, и получилось примерно следующее:
SQL Create table
CREATE TABLE `users` (
  `user_id` int(11) NOT NULL AUTO_INCREMENT,
  `user_name` varchar(100) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT 'ФИО пользователя',
  `extension` varchar(5) CHARACTER SET utf8 DEFAULT '000' COMMENT 'Внутренний номер абонента',
  `mobile_limit_flag` int(11) DEFAULT '0' COMMENT 'Флаг для учета текущего лимита',
  `mobile_limit` int(11) DEFAULT '0' COMMENT 'Текущий лимит',
  `base_mobile_limit` int(11) DEFAULT NULL COMMENT 'Базовый лимит',
  PRIMARY KEY (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=33 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci


Описание основных параметров:
  • base_mobile_limit хранит индивидуальный лимит абонента (в секундах), устанавливается единовременно;
  • mobile_limit содержит текущий лимит на текущий день, с учетом не израсходованных минут;
  • mobile_limit_flag определяет какой порог исчерпания лимита преодолел пользователь (0 — <50%, 1 — >50 и <90%, 2 — >90% и <100%, 3 — >100%);

Создадим Васю Пупкина, с уже известным нам внутренним номером 2055.



Приступаем к формированию основной системы, логика следующая:
  • по расписанию (по крону) скрипт проверяет сколько наговорил каждый абонент по нужным нам направлениям;
  • если абонент перешел порог 0,1 или 2, параметр mobile_limit_flag меняется на соответствующий и отправляется сообщение на email;
  • если абонент оказался на пороге 3 (лимит полностью исчерпан), отправляется соответствующее уведомление на email, в конфигурационном файле комментируется соответствующий блок, выполняется dialplan reload.

Для хранения позиций блоков соответствующих внутренних номеров, сформируем XML файл следующего вида:

XML
<?xml version="1.0" encoding="UTF-8" ?>
<bocks>
	<!-- BLOCKS START -->
	<block number="2055">
		<element first="4" last="11"/>
		<element first="117" last="124"/>
		<element first="230" last="237"/>
	</block>
	<block number="2066">
		<element first="14" last="21"/>
		<element first="127" last="134"/>
		<element first="240" last="247"/>
	</block>
	<block number="2077">
		<element first="24" last="31"/>
		<element first="137" last="144"/>
		<element first="250" last="257"/>
	</block>
<bocks>


Так как мы создали три маски для выхода на мобильные номера, для каждого внутреннего номера будет по три разрешающих блока. В XML мы указываем номера строк начала и конца каждого из этих блоков. Комментируем их с помощью следующего кода:

Функция комментирования блока
def commentBlocks(numb):
	import xml.etree.cElementTree as ET
	tree = ET.ElementTree(file='conf.xml')
	root = tree.getroot()
	f = open(r'extensions_custom.conf')
	lines = f.readlines()
	f.close()
	for elem in tree.iterfind('block[@number="'+numb+'"]/element'):
	    lines[int(elem.get('first'))-2] = ";--\n"
	    lines[int(elem.get('last'))] = "--;\n"
	f = open(r'extensions_custom.conf','w')
	f.writelines(lines)
	f.close()


И собственно основные мозги:

Main script
Дергается по расписанию, например, каждые 5 минут. Бонусом великолепный SQL запрос, и божественный код.
#мои функции
import send_email
import flags

print ('###########START_MOBILE_LIMIT############')

import pymysql
mainconn = pymysql.connect(host='10.10.2.1', user='user', passwd='password', db='asteriskcdrdb', charset='utf8')
maincur = mainconn.cursor()
maincur.execute("""SELECT SUM(billsec) AS sec, src 
	FROM cdr WHERE disposition = 'ANSWERED' 
	AND (dst LIKE '8700%' OR dst LIKE '8701%' 
	OR dst LIKE '8702%' OR dst LIKE '8705%' OR dst LIKE '8707%' 
	OR dst LIKE '8708%' OR dst LIKE '8747%' OR dst LIKE '8771%' 
	OR dst LIKE '8775%' OR dst LIKE '8776%' OR dst LIKE '8777%' 
	OR dst LIKE '8778%') AND DATE(calldate) = DATE(CURDATE()) 
	AND src in (2055,2066,2077)
	GROUP BY src;""")

row = maincur.fetchone()

print ('ROW COUNT: ' + str(self.maincur.rowcount))

while row is not None:

	#row[1] - внутренний номер
	#row[0] - исчерпанный лимит в секундах

	#изменяем текущий лимит
	flags.UpdateUserCurrentLimit(str(row[1]), str(row[0]))

	per = row[0] * 100 / flags.checkUserLimit(row[1])
	flag = flags.checkFlag(row[1])
	#вытаскиваем почтовый ящик абонента, ищем его по внутреннему номеру
	manager_mail = send_email.getEmail(row[1])

	#показываем % израсходонного трафика
	print (row[1] + ' (' + str(round(per,0)) + '%): ' + str(row[0]))

	#проверяем порог
	if per >= 50 and per < 90:
		message = 'Nomer ' + row[1] + ', limit ischerpan na ' + str(round(per, 0)) + '%'
		if flag == 0:
			print ('go email to ' + send_email.getEmail(row[1]))
			send_email.send_message(manager_mail, message)
			flags.changeFlag(row[1], 1)
			flags.insertLog(row[1], per)
		print (message)
	elif per > 90 and per < 100:
		message = 'Nomer ' + row[1] + ', limit ischerpan na ' + str(round(per, 0)) + '%'
		if flag == 1:
			print ('go email to ' + send_email.getEmail(row[1]))
			send_email.send_message(manager_mail, message)
			flags.changeFlag(row[1], 2)
			flags.insertLog(row[1], per)
		print (message)
	elif per >= 100:
		message = 'Nomer ' + row[1] + ', limit polnostiu ischerpan'
		if flag != 3:
			print ('go email to ' + send_email.getEmail(row[1]))
			send_email.send_message(manager_mail, message)
			flags.changeFlag(row[1], 3)
			flags.insertLog(row[1], per)
			#комментируем соответствующие блоки в конфиге
			flags.commentBlocks(str(row[1]))
			import subprocess
			#дергаем диалплан чтобы применить настройки
			subprocess.call(['./dialplan_reload.sh'])
		print (message)

	row = maincur.fetchone()

maincur.close()
mainconn.close()

print ('############END_MOBILE_LIMIT#############')


В конце рабочего дня мы добавляем неиспользуемый лимит:

AddUnusedLimit
def AddUnusedLimit(ext):
	conn = pymysql.connect(host='10.10.2.2', user='user', passwd='password', db='crm', charset='utf8')
	cur = conn.cursor()

	cur.execute ("""
	   UPDATE users
	   SET mobile_limit=base_mobile_limit+mobile_limit WHERE extension=%s
	""", (ext))

	conn.commit()
	print('changed', cur.rowcount)
	cur.close()
	conn.close()


Сбрасываем mobile_limit_flag на дефолтное значение 0 и раскоменчиваем все блоки:

uncommentBlocks
def uncommentBlocks():
	import xml.etree.cElementTree as ET
	tree = ET.ElementTree(file='conf.xml')
	root = tree.getroot()
	for elem in tree.iterfind('block/element'):
		first = int(elem.get('first'))
		last = int(elem.get('last'))
		lines[first-2] = "\n"
		lines[last] = "\n"
f = open(r'/etc/asterisk/extensions_custom.conf')
lines = f.readlines()
f.close()

############################################

cur.execute ("""
	   UPDATE users
	   SET mobile_limit_flag=%s
	""", (0))


Для решения возможных спорных ситуаций, пишем в лог данные об изменении порога лимита:

LOG
#функция логирования израсходованного лимита
def insertLog(ext, per):
	import pymysql
	conn = pymysql.connect(host='10.10.2.1', user='user', passwd='password', db='crm', charset='utf8')
	cur = conn.cursor()

	cur.execute ("""
	   INSERT INTO mobile_limit
	   (extension, percent)
	   VALUES (%s, %s)
	""", (ext, per))

	conn.commit()
	print('insert', cur.rowcount)
	cur.close()
	conn.close()


Вот такое кривое решение поставленной задачи. В версии скрипта 2.0 мы будем динамически формировать разрешающие блоки, что позволит более гибко использовать систему, при каких-либо изменениях.
Поделиться с друзьями
-->

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


  1. bigbrotherwatchingyou
    19.08.2016 11:46
    +2

    Зачем ограничивать в свЯзи сотрудников, занимающихся обзвоном клиентов? Сэкономил сегодня на звонках — доставил проблему сотруднику — остался завтра без клиента — послезавтра без работы. Рубите сук, на котором сидите, экономисты блин…


    1. Evgenius0307
      19.08.2016 11:51

      Возможно, Вы правы. Но это не мое дело. Для меня, как it специалиста, задача стоит ограничить звонки. А увеличение выручки, поиск клиентов и другие подобные вопросы решает коммерческий отдел.


      1. 640509-040147
        19.08.2016 16:15

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


        1. Evgenius0307
          19.08.2016 16:47

          А вы считаете, если обычный айтишник будет советовать руководителям отделов продаж и маркетинга, как им строить планы продаж, его повысят в должности, а не попросят «написать по собственному», и идти со своими советами куда подальше?
          Я, конечно, понимаю что это можно назвать, как сказал товарищ bigbrotherwatchingyou: «Рубите сук, на котором сидите», но не в моей компетенции тут принимать решения.


          1. 640509-040147
            19.08.2016 18:57

            Но ведь можно не лезть с советами, а, после получения такой задачи, вежливо спросить: «А не позволите ли полюбопытствовать, милое руководство, с чем связаны такие меры? Али времена в конторе сложные наступают? А не зааффектит ли это коим-то образом наш многострадальный it-департамент». На своём опыте знаю, что как только в компании начинается режим экономии (на чем бы то ни было), то нужно держать ушки на макушке.


      1. xmaster83
        22.08.2016 20:14

        Мой вам совет раз так обстоят дела, уходите с этой конторы, а то скоро на вашей зарплате начнут экономить, а сейчас программисты в цене + voip инженер счас начинает цениться


  1. antirek
    19.08.2016 12:25

    Наверное, можно было бы вынести всю логику во внешнее приложение AGI, которое бы вам в диалплан возвращало только значение можно звонить или нет, не надо было бы переписывать диалплан.
    Использование и xml, и бд? Можно все хранить в БД?
    Зачем выкладывать куски кода на хабр? Выложит их в репо на гитхаб — можно почитать, посмотреть, попробовать.


    1. Evgenius0307
      19.08.2016 12:46

      Полностью с Вами согласен. Через AGI реализация была был намного гибчи и проще. Но на данный момент я не на столько хорошо владею всей этой кухней. Есть в планах реализовать этот сервис на AGI.
      Насчет БД и XML — мне просто хотелось потренироваться на Python'e работать с этими форматами, этот язык я только начинаю изучать. Какие хранилища данных использовать, и как их совмещать — личный выбор каждого. В какой-то ситуации это может быть оправдано, а в какой-то нет. Нюансов может быть очень много.
      Код не на столько гибкий, чтобы можно было просто взять и попробовать на своей АТС. Эта статья просто пример, как можно реализовать подобную задачу. Как только это станет приближенным к «готовому решению», будет репа. Но опять же, в идеале я бы реализовал это на AGI.


      1. xmaster83
        22.08.2016 20:21

        А может ARI?


  1. Darigaaz
    19.08.2016 16:28

    Не очень хорошо перезаписывать кусок диалплана сгенерированный freepbx. Для этого в [macro-dialout-trunk], который отвечает за звонки через транки, сделан вызов макроса Macro(dialout-trunk-predial-hook,), в который вы и должны поместить свою логику. Там будут доступны переменные DIAL_TRUNK, DIAL_NUMBER, полный список можете получить вызвав DumpChan() в этом макросе и посмотреть в консоль при исходящем звонке.

    Подробнее в extensions.conf
    ;-------------------------------------------------------------------------------
    ; macro-dialout-trunk-predial-hook:
    ;
    ; this macro intentionally left blank so it may be safely overwritten for any custom
    ; requirements that an installation may have.
    ;
    ; the macro is called by macro-dialout-trunk just prior to making a Dial() attempt
    ; to a trunk.
    ;
    ; MACRO RETURN CODE: ${PREDIAL_HOOK_RET}
    ; if set to "BYPASS" then this trunk will be skipped
    ;
    ;
    [macro-dialout-trunk-predial-hook]
    exten => s,1,MacroExit()
    ;-------------------------------------------------------------------------------


    1. Evgenius0307
      19.08.2016 16:38

      Благодарю за информацию! Попробую сделать через Macro.
      Сделал именно таким образом, потому что:
      а. имею минимальный опыт работы с конфигурационными файлами Asterisk'a;
      b. FreePBX перезаписывает основные конфиги после применения настроек, и приходится «выносить» нужные мне блоки за его пределы.


      1. Darigaaz
        20.08.2016 05:17

        FreePBX перезаписывает основные конфиги после применения настроек, и приходится «выносить» нужные мне блоки за его пределы.

        Поэтому в конфигах FreePBX существует большое количество -custom контекстов, которые предназначены именно для этих целей. (Если у вас их нет, проверьте опцию FreePBX — Settings — Advanced Settings — «Disable -custom Context Includes».) Многие задачи можно решить с их помощью не прибегая к перезаписыванию сгенерированных конфигов. Еще часть можно решить с помощью Custom Destinations.