Задача:

1) Есть группа операторов Call-центра, нужно сделать возможность после разговора, оценивать звонок.
2) Максимальная интеграция с FreePBX.

Решение:

В Интернете много вариантов, все они ориентированы на чистый Asterisk.
Примеры:
https://wiki.merionet.ru/ip-telephoniya/55/ocenka-raboty-operatora-posle-zvonka-na-asterisk/
https://habr.com/ru/post/147122/
http://asterisk-service.com/blog/asterisk-1/post/2-0-53
Моя же реализация заключается в том, что все настройки можно делать в Web-интерфейсе FreePBX.
Реализация тестировалась и работает на:
— версии Asterisk 1.8.10.1
— FreePBX версии 12.0.76.6
— PHP версии 5.3.10-1ubuntu3.26
— библиотека PHPAGI версии 2.20 2010/09/30 02:21:00 (качаем тут: phpagi.zip)

Первым делом нам нужно определить как генерируется постановка вызова в очередь. Выполняем на сервере Asterisk:
nano /etc/asterisk/extensions_additional.conf
Ищем контекст с названием [ext-queues]. Внутри должны быть строки вида

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
[ext-queues]
include => ext-queues-custom
exten => 11,1,Macro(user-callerid,)
exten => 11,n,Answer
exten => 11,n,Macro(blkvm-set,reset)
exten => 11,n,ExecIf($["${REGEX("(M[(]auto-blkvm[)])" ${DIAL_OPTIONS})}" != "1"]?Set(_DIAL_OPTIONS=${DIAL_OPTIONS}M(auto-blkvm)))
exten => 11,n,Set(__NODEST=${EXTEN})
exten => 11,n,Set(QCIDPP=${IF($[${LEN(${VQ_CIDPP})}>0]?${VQ_CIDPP}: )})
exten => 11,n,Set(VQ_CIDPP=)
exten => 11,n,ExecIf($["${QCIDPP}"!=""]?Macro(prepend-cid,${QCIDPP}))
exten => 11,n,Set(QAINFO=${IF($[${LEN(${VQ_AINFO})}>0]?${VQ_AINFO}: )})
exten => 11,n,Set(VQ_AINFO=)
exten => 11,n,ExecIf($["${QAINFO}"!=""]?Set(__ALERT_INFO=${QAINFO}))
exten => 11,n,Set(QJOINMSG=${IF($[${LEN(${VQ_JOINMSG})}>0]?${IF($["${VQ_JOINMSG}"!="0"]?${VQ_JOINMSG}: )}:custom/zednannja-vstanovleno)})
exten => 11,n,Set(VQ_JOINMSG=)
exten => 11,n,Set(QRINGOPTS=r)
exten => 11,n,Set(QRETRY=${IF($[${LEN(${VQ_RETRY})}>0]?${VQ_RETRY}:n)})
exten => 11,n,Set(VQ_RETRY=)
exten => 11,n(qoptions),Set(QOPTIONS=${IF($[${LEN(${VQ_OPTIONS})}>0]?${VQ_OPTIONS}:t)}${QCANCELMISSED}${QRINGOPTS}${QRETRY})
exten => 11,n,Set(VQ_OPTIONS=)
exten => 11,n(qgosub),Set(QGOSUB=${IF($[${LEN(${VQ_GOSUB})}>0]?${VQ_GOSUB}:${QGOSUB})})
exten => 11,n,Set(VQ_GOSUB=)
exten => 11,n(qagi),Set(QAGI=${IF($[${LEN(${VQ_AGI})}>0]?${VQ_AGI}:${QAGI})})
exten => 11,n,Set(VQ_AGI=)
exten => 11,n(qrule),Set(QRULE=${IF($[${LEN(${VQ_RULE})}>0]?${IF($["${VQ_RULE}"!="0"]?${VQ_RULE}: )}:${QRULE})})
exten => 11,n,Set(VQ_RULE=)
exten => 11,n(qposition),Set(QPOSITION=${IF($[${LEN(${VQ_POSITION})}>0]?${VQ_POSITION}:${QPOSITION})})
exten => 11,n,Set(VQ_POSITION=)
exten => 11,n,Gosub(sub-record-check,s,1(q,11,dontcare))
exten => 11,n,ExecIf($["${QJOINMSG}"!=""]?Playback(${QJOINMSG}, ))
exten => 11,n,QueueLog(11,${UNIQUEID},NONE,DID,${FROM_DID})
exten => 11,n,Set(QAANNOUNCE=${IF($[${LEN(${VQ_AANNOUNCE})}>0]?${IF($["${VQ_AANNOUNCE}"!="0"]?${VQ_AANNOUNCE}: )}: )})
exten => 11,n,Set(VQ_AANNOUNCE=)
exten => 11,n,Set(QMOH=${IF($["${VQ_MOH}"!=""]?${VQ_MOH}: )})
exten => 11,n,Set(VQ_MOH=)
exten => 11,n,ExecIf($["${QMOH}"!=""]?Set(__MOHCLASS=${QMOH}))
exten => 11,n,ExecIf($["${MOHCLASS}"!=""]?Set(CHANNEL(musicclass)=${MOHCLASS}))
exten => 11,n,Set(QMAXWAIT=${IF($[${LEN(${VQ_MAXWAIT})}>0]?${VQ_MAXWAIT}: )})
exten => 11,n,Set(VQ_MAXWAIT=)
exten => 11,n,Set(QUEUENUM=11)
exten => 11,n,Set(QUEUEJOINTIME=${EPOCH})
exten => 11,n(qcall),Queue(11,${QOPTIONS},,${QAANNOUNCE},${QMAXWAIT},${QAGI},,${QGOSUB},${QRULE},${QPOSITION})
exten => 11,n,Macro(blkvm-clr,)
exten => 11,n,Gosub(sub-record-cancel,s,1())
exten => 11,n,Set(__NODEST=)
exten => 11,n,Set(_QUEUE_PRIO=0)
exten => 11,n,Set(QRINGOPTS=)
exten => 11,n,Set(QDEST=${VQ_DEST})
exten => 11,n,Set(VQ_DEST=)
exten => 11,n(gotodest),GotoIf($["${QDEST}"=""]?ext-group,505,1:${CUT(QDEST,^,1)},${CUT(QDEST,^,2)},${CUT(QDEST,^,3)})

здесь нас интересует строка: exten => 11,n(qcall),Queue(11,${QOPTIONS},,${QAANNOUNCE},${QMAXWAIT},${QAGI},,${QGOSUB},${QRULE},${QPOSITION})
Приложение Queue() генерирует постановку вызова в очередь. Как видим с примера — приложению передаются параметры, которые были сгенерированные перед выполнением приложения Queue(). Смотрим в документацию: https://asterisk-pbx.ru/wiki/asterisk/app/queue
Как видим по документации, если при выполнении приложения Queue(), добавить в аргумент «options» параметр «c», то диаплан продолжит свое выполнения, когда вызываемый (оператор) положил трубку. В нашем случае — диаплан перейдет на пункт «Fail Over Destination» в FreePBX.
По контексту определяем, что если перед поступлением звонка в очередь, мы установим значение «c» переменной VQ_OPTIONS, то приложение Queue() згенерирует постановку вызова в очередь с этим параметом.

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

nano /etc/asterisk/extensions_custom.conf
Создаем собственный контекст с установкой значения переменной VQ_OPTIONS:

1
2
3
4
[queue-call-begin]
exten => s,1,Set(VQ_OPTIONS=tc) ;на первом приоритете, мы устанавливаем значение переменной. Параметром "t" - я разрешаю оператору переводить звонки
exten => s,n,System(php /var/www/call/call.php incall ${UNIQUEID} ${CALLERID(num)} ${CHANNEL} &) ;запуск фонового php скрипта на выполнения
exten => s,n,Return ;возвращаемся на предыдущий контекст, откуда был вызван

Также создаем контексты с оценками

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[assessment-1]
exten => s,1,Set(__OcenkaOper=1) ;выполняем необходимое действие
exten => s,n,Return ;возвращаемся на предыдущий контекст, откуда был вызван
 
[assessment-2]
exten => s,1,Set(__OcenkaOper=2) ;выполняем необходимое действие
exten => s,n,Return ;возвращаемся на предыдущий контекст, откуда был вызван
 
[assessment-3]
exten => s,1,Set(__OcenkaOper=3) ;выполняем необходимое действие
exten => s,n,Return ;возвращаемся на предыдущий контекст, откуда был вызван
 
[assessment-4]
exten => s,1,Set(__OcenkaOper=4) ;выполняем необходимое действие
exten => s,n,Return ;возвращаемся на предыдущий контекст, откуда был вызван
 
[assessment-5]
exten => s,1,Set(__OcenkaOper=5) ;выполняем необходимое действие
exten => s,n,Return ;возвращаемся на предыдущий контекст, откуда был вызван


После exten => s,1,Set(__OcenkaOper=X) можем выполнять еще команды. Например отправить сообщение в телеграмм:
exten => s,n,System(curl -s -X POST "https://api.telegram.org/bot545454443:AAAAAAAAAAAAAAAABBBBBBBBBBBCCC/sendmessage" -F chat_id="20202020" -F text="Оставлена оценка оператору: 5" &)
Или можно запустить php скрипт, который возвращает значение. Это значение присвоим переменной:
exten => s,n,Set(CallId=${SHELL(php /var/www/call/call.php inCallRegister ${CALLERID(num)})})

Логично, что если звонок не состоялся (оператор не взял трубку), то предлагать оценивать вызов не нужно. Для этого, создадим еще один контекст:

1
2
3
4
5
[queue-call-end]
exten => s,1,GotoIf($[${QUEUESTATUS}=TIMEOUT]?timeout:answered); очередь по завершению получает переменную QUEUESTATUS, от значения которого мы можем определить был ли принят звонок
exten => s,n(timeout),Goto(app-announcement-18,s,1) ; если звонок не был принят, направим его на app-announcement-18,s,1 - это приветствие, в котором говорим, что сейчас все операторы заняты и мы ему перезвоним. После, терминируем звонок (hangup) - обязательно. 
exten => s,n(answered),Noop(return to queue-call-end) ; если статус звонка отличается от "TIMEOUT", сделаем вывод, что звонок состоялся, и переходим на следующий приоритет
exten => s,n,Return ;возвращаемся на предыдущий контекст, откуда был вызван

Сохраняем изменения в файле /etc/asterisk/extensions_custom.conf и выходим с редактора nano — Ctrl+o, Ctrl+x.
Следующим этапом нам нужно зафиксировать созданные контексты в FreePBX.
Заходим в FreePBX → АДМИНИСТРАТОР (Admin) → Дополнительные назначения (Custom Destination)
Добавляем созданные ранее назначения:
Для примера опишу добавление контекста [queue-call-begin]
— в поле Спецназначение: указываем «queue-call-begin,s,1»
— в поле Описание: указываем «queue-call-begin» — название понятное Вам
— в поле Примечания: указываем описание для назначения понятное Вам
— Обязательно ставим галочку «Return», после чего появится выпадающий список дальнейших назначений — направляем в очередь (можно в другое место).

По аналогии, добавляем остальные назначения:
[assessment-1]
Спецназначение: assessment-1,s,1
Return: Приветствие → «Спасибо за оценку» → Терминовать звонок (Положить трубку).

[assessment-2]
Спецназначение: assessment-2,s,1
Return: Приветствие → «Спасибо за оценку» → Терминовать звонок (Положить трубку).

[assessment-3]
Спецназначение: assessment-3,s,1
Return: Приветствие → «Спасибо за оценку» → Терминовать звонок (Положить трубку).

[assessment-4]
Спецназначение: assessment-4,s,1
Return: Приветствие → «Спасибо за оценку» → Терминовать звонок (Положить трубку).

[assessment-5]
Спецназначение: assessment-5,s,1
Return: Приветствие → «Спасибо за оценку» → Терминовать звонок (Положить трубку).

[queue-call-end]
Спецназначение: queue-call-end,s,1
Return: IVR → «Оцените работу оператора».

Настройка Очереди:
Все настройки делаем на свое усмотрение, но в «Назначение при неответе», устанавливаем «Дополнительные назначения» → «queue-call-end».

Настройка IVR «Оцените работу оператора»:

Применяем настройки. В терминале сервера можно выполнить asterisk -rx "dialplan reload"

Что у нас получается на этот момент:
Поступает звонок. Первым делом — направляем его на «Дополнительные назначения» → «queue-call-begin», где присваиваем переменной VQ_OPTIONS значение «tc» (параметр c — продолжение выполнения диалплана после того, как оператор положил трубку). Далее звонок поступает в очередь, где генерируются вызовы агентам (операторам). Оператор поднял трубку → разговор → положил трубку. Звонок попадает в «Назначение при неответе» → «Дополнительные назначения» → «queue-call-end», где мы проверяем был ли отвечен звонок. Если звонок не был принят (TIMEOUT), направляем его на Приветствие app-announcement-18 (у Вас будет другое, ищите в nano /etc/asterisk/extensions_additional.conf).Если статус звонка (QUEUESTATUS) отличается от «TIMEOUT», продолжаем выполнение диалплана, где попадаем в Return Дополнительного назначения «queue-call-begin» → IVR «Оцените работу оператора». Здесь мы озвучиваем запись «Поставьте оценку от 1-го до 5-ти», ждем ввода. Если клиент ничего не ввел, либо ввел неверно, Терминируем звонок (Положить трубку). Если нажал одну с оценок, переходим в «Дополнительные назначение» → «assessment-X», где присваиваем переменной OcenkaOper определенное значение, и возвращаемся в поле Return Дополнительного назначения «assessment-X» → Приветствие «Спасибо за оценку». Приветствие «Спасибо за оценку» воспроизводит запись о благодарности за оценку и Терминирует звонок (Положить трубку).

Посмотреть логи прохождения звонка можно командой asterisk -rvvvv

Теперь нам нужно следить за звонком. Создаем каталог «call» в /var/www/, внутри него создаем php файл с именем call.php. Также внутри каталога call создаем каталог src и в корень его кладем файлы библиотеки PHPAGI
nano /var/www/call/call.php
Вставляем код:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
<?php
 
require('/var/www/call/src/phpagi.php');
 
/*
следующие строки - подключение к asterisk AMI, доступы посмотреть можно здесь: /etc/asterisk/manager.conf, либо создать дополнительный доступ:  https://wiki.merionet.ru/ip-telephoniya/13/nastrojka-ami-v-freepbx-13/
*/
$service_port = "5038";
$address = "127.0.0.1";
$user_ami = "admin";
$pass_ami = "admin";
 
$token_telegram='44444444444:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-UaaUUUU';
$chat_id_telegram='222222222222';
 
$responsible=NULL;
$stat=false; //статус звонка (начат разговор=true, или нет=false). принимает значение true\false
$ocenka=NULL; //переменная, хранит в себе поставленную оценку
$Uniqueid1='0'; //Глобальная переменная, которая будет изменятся по циклу работы скрипта (перевод звонка, и т.п.)
$Uniqueid2='0'; //Глобальная переменная, которая будет изменятся по циклу работы скрипта (перевод звонка, и т.п.)
$Uniqueid='0';  //Глобальная переменная, которая НЕ будет изменятся, она статическая на все время работы скрипта (перевод звонка и т.п.)
$UniqueID='0';  //Глобальная переменная, которая будет изменятся по циклу работы скрипта (перевод звонка, и т.п.)
$CallerID1='0';  //Глобальная переменная, которая будет изменятся по циклу работы скрипта (перевод звонка, и т.п.)
$CallerID2='0';  //Глобальная переменная, которая будет изменятся по циклу работы скрипта (перевод звонка, и т.п.)
$Channel1='0';  //Глобальная переменная, которая будет изменятся по циклу работы скрипта (перевод звонка, и т.п.)
$Channel2='0';  //Глобальная переменная, которая будет изменятся по циклу работы скрипта (перевод звонка, и т.п.)
$i=true;  //Глобальная переменная, которая определяет цикл работы скрипта. true = продолжает работать скрипт, false = скрипт закончит работу
$redirekt=false; //Статус перевода звонка
$per=false; //временный статус перевода звонка
 
function telegram_message($TOKEN='',$CHAT_ID='',$MESSAGE=''){
$telegram_data = array (
    'text' => $MESSAGE,
    'chat_id' => $CHAT_ID,
    'parse_mode' => 'html'
);
file_get_contents("https://api.telegram.org/bot".$TOKEN."/sendMessage?".http_build_query($telegram_data));
}
 
function dump_incall($ecode,$data,$server,$port) {
//telegram_message($GLOBALS["token_telegram"],$GLOBALS["chat_id_telegram"],'перевод звонка '.json_encode($data));
if(((($data['Event']=='VarSet')&&($data['Value']=='tr'))&&($data['Variable']=='ARG2'))&&(($data['Channel']==$GLOBALS["Channel1"])||($data['Channel']==$GLOBALS["Channel2"])))
  $GLOBALS["per"]=true; //определяем перевод звонка
if(((($data['Event']=='VarSet')&&($GLOBALS["stat"]==true))&&($data['Variable']=='ARG3'))&&(($data['Channel']==$GLOBALS["Channel1"])||($data['Channel']==$GLOBALS["Channel2"]))){
  $GLOBALS["per"]=false;
  $GLOBALS["UniqueID"]=$data['Uniqueid'];
  $GLOBALS["stat"]=false;
  $GLOBALS["redirekt"]=true;
  //telegram_message($GLOBALS["token_telegram"],$GLOBALS["chat_id_telegram"],'перевод звонка '.json_encode($data));
}
 
if(($data['Event']=='Newstate')&&($GLOBALS["CallerIdNum"]==$data['ConnectedLineNum']))
  if(($data['ChannelStateDesc']=='Up')&&(preg_match("/^SIP/",$data['Channel']))) $GLOBALS["UniqueID"]=$data['Uniqueid'];
 
if(($GLOBALS["UniqueID"]==$data['Uniqueid2'])||($GLOBALS["UniqueID"]==$data['Uniqueid1'])||($GLOBALS["UniqueID"]==$data['Uniqueid'])||($GLOBALS["UniqueID"]==$data['UniqueID'])){
  if(($data['Event']=='Bridge')&&($data['Bridgestate']=='Link')){  //поднята трубка
    if($GLOBALS["stat"]==false){
      $GLOBALS["stat"]=true;
      $GLOBALS["responsible"]=$data['CallerID2'];       //определяем ответственного за звонок
      $GLOBALS["Uniqueid2"]=$data['Uniqueid2'];
      $GLOBALS["Uniqueid1"]=$data['Uniqueid1'];
      $GLOBALS["Channel1"]=$data['Channel1'];
      $GLOBALS["Channel2"]=$data['Channel2'];
      //telegram_message($GLOBALS["token_telegram"],$GLOBALS["chat_id_telegram"],'Поднята трубка. начат разговор '.json_encode($data));
    }
  }
  if((($data['Event']=='Dial')&&($data['SubEvent']=='Begin'))&&($GLOBALS["redirekt"]==true)){
      $GLOBALS["responsible"]=$data['Dialstring'];
      //telegram_message($GLOBALS["token_telegram"],$GLOBALS["chat_id_telegram"],'зафиксирован перевод звонка. Новые данные: CallerIDNum='.$data['CallerIDNum'].' Dialstring='.$data['Dialstring'].' '.json_encode($data));
      $GLOBALS["redirekt"]=false;
      $GLOBALS["Channel1"]=$data['Channel'];
      $GLOBALS["Channel2"]=$data['Destination'];
      $GLOBALS["UniqueID"]=$data['UniqueID'];
      $GLOBALS["Uniqueid1"]=$data['UniqueID'];
      $GLOBALS["Uniqueid2"]=$data['DestUniqueID'];
  }
}
if($GLOBALS["Uniqueid"]==$data['Uniqueid'])
  if($data['Event']=='Hangup') {
    telegram_message($GLOBALS["token_telegram"],$GLOBALS["chat_id_telegram"],'Завершена работа скрипта. выход из цыкла '.json_encode($data));
    $GLOBALS["i"]=false;
  }
}
 
if (isset($argv[1])) {
        $status=$argv[1];
}
else  echo "Введите параметры!";
if($status=='incall'){
    if ((isset($argv[2])&&isset($argv[3]))&&isset($argv[4])) {
                $Uniqueid=$argv[2];
                $CallerIdNum=$argv[3];
                $Channel=$argv[4];
                $manager = new AGI_AsteriskManager();
                $manager->connect($address, $user_ami, $pass_ami);
                //telegram_message($token_telegram,$chat_id_telegram,'Входящий звонок. id='.$Uniqueid.' caller='.$CallerIdNum.' channel='.$Channel);
                    while($i){
                      $manager->Events('on');
                      $manager->wait_response(true);
                      $manager->add_event_handler('*', 'dump_incall');
                      if(!$ocenka) {
                        $tmpOc=$manager->send_request('GetVar',array('Variable' => 'OcenkaOper', 'Channel' => $Channel));
                        if(isset($tmpOc['Value']) && ($tmpOc['Value']!='')){
                          $ocenka=$tmpOc['Value'];
                          //telegram_message($token_telegram,$chat_id_telegram,'ocenka='.json_encode($tmpOc));
                        }
                      }
                    }
                $manager->disconnect();
                if(isset($ocenka)) {
                  if($ocenka=='5') $smile="\xF0\x9F\x98\x8D";
                  if($ocenka=='4') $smile="\xF0\x9F\x98\x83";
                  if($ocenka=='3') $smile="\xF0\x9F\x98\x8F";
                  if($ocenka=='2') $smile="\xF0\x9F\x98\x96";
                  if($ocenka=='1') $smile="\xF0\x9F\x98\xA1";
                  telegram_message($token_telegram,$chat_id_telegram,$smile." Оценен входящий звонок от ".$CallerIdNum.". Ответственный оператор ".$responsible.". Оставлена оценка ".$ocenka);
                }
                //telegram_message($token_telegram,$chat_id_telegram,'Завершение скрипта по номеру канала '.$Uniqueid.'. ответственный='.$responsible);
    }
    else echo "Введите параметры!";
}
?>

Вносим свои изменения, сохраняем и выходим: CTRL+o, CTRL+x.
Что делает скрипт? Он запускается в контексте [queue-call-begin], и следит за звонком до самого его завершения. Он определяет кто поднят трубку, на кого звонок был перенапрвален, и была ли оставлена оценка. После чего — отправляет сообщение в телеграм. Вместо телеграма, можно отправлять оценку куда угодно (SQL, 1С, Битрикс24, и т.д.)
Делаем тестовый звонок, проверяем работоспособность, запускаем в продакшн.