/
mikopbx
/
ModuleBitrix24Integration
Обзор
Документация
Войти
/
mikopbx
/
ModuleBitrix24Integration
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
v1.274
bin/WorkerBitrix24IntegrationAMI.php
867 строк
40 KB
Alexey Portnov
register/show: пропуск плеч без B24-пользователя
13 май 2026, 20:20
13 май 2026, 20:20
25a99cc
Код
Авторство
О чём код?
<?php /* * MikoPBX - free phone system for small business * Copyright © 2017-2023 Alexey Portnov and Nikolay Beketov * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with this program. * If not, see <https://www.gnu.org/licenses/>. */ /* * Copyright © MIKO LLC - All Rights Reserved * Unauthorized copying of this file, via any medium is strictly prohibited * Proprietary and confidential * Written by Alexey Portnov, 10 2020 */ namespace Modules\ModuleBitrix24Integration\bin; require_once 'Globals.php'; use MikoPBX\Common\Models\PbxSettings; use MikoPBX\Core\System\BeanstalkClient; use Exception; use MikoPBX\Core\System\MikoPBXConfig; use MikoPBX\Core\Workers\WorkerBase; use MikoPBX\Common\Models\Extensions; use MikoPBX\Core\Workers\WorkerCdr; use Modules\ModuleBitrix24Integration\Lib\AsteriskManager; use Modules\ModuleBitrix24Integration\Lib\Bitrix24Integration; use Modules\ModuleBitrix24Integration\Models\ModuleBitrix24Integration; use Modules\ModuleBitrix24Integration\Lib\Logger; class WorkerBitrix24IntegrationAMI extends WorkerBase { /** @var Bitrix24Integration $b24*/ private Bitrix24Integration $b24; private array $inner_numbers = []; private int $counter = 0; private array $msg = []; private int $extensionLength; private array $extensions = []; private bool $export_records = false; private bool $export_cdr = false; private string $leadType = Bitrix24Integration::API_LEAD_TYPE_ALL; private array $external_lines = []; private array $disabledDid = []; private bool $crmCreateLead = false; private BeanstalkClient $client; private array $outChannels = []; private string $processState = 'init'; private array $channelCounter = []; private string $responsibleMissedCalls = ''; private Logger $logger; /** * Handles the received signal. * * @param int $signal The signal to handle. * * @return void */ public function signalHandler(int $signal): void { parent::signalHandler($signal); if (isset($this->logger)) { $this->logger->writeInfo("Need SHUTDOWN (state={$this->processState})... $signal"); } cli_set_process_title("SHUTDOWN[{$this->processState}]_" . self::class); try { $this->am->setBreak(); } catch (\Throwable $e) { // $am not initialized yet, will exit naturally } } /** * Старт работы листнера. * * @param $argv */ public function start($argv):void { $this->logger = new Logger('IntegrationAMI', 'ModuleBitrix24Integration'); $this->logger->writeInfo('Start daemon...'); $this->client = new BeanstalkClient(Bitrix24Integration::B24_INTEGRATION_CHANNEL); $this->am = $this->createAstManager(); // ВАЖНО: AMI-фильтр и обработчик регистрируем ДО тяжёлого new // Bitrix24Integration (он делает синхронный REST к B24 и может занимать // секунды). safe.php (WorkerSafeScriptsCore::checkWorkerAMI) шлёт ping // через UserEvent каждые ~5 сек и засчитывает 3 промаха подряд → SIGUSR1. // Если listener не зарегистрирован к этому моменту, воркер крашится // в цикле «Start daemon → Need SHUTDOWN... 10 → Start daemon». Ping- // обработчик (replyOnPingRequest) зависит только от $this->am, поэтому // безопасно выводить его регистрацию вперёд. $this->setFilter(); $this->am->addEventHandler('userevent', [$this, 'callback']); $this->b24 = new Bitrix24Integration('_ami'); if (!$this->b24->initialized) { $this->logger->writeError('Settings not set... exit'); exit(); } // Bitrix24Integration уже подгрузил настройки через ConnectorDb::invoke // и применил уровень к своему логгеру — синхронизируемся. $this->logger->setLevel($this->b24->mainLogger->getLevel()); $this->updateSettings(); $config = new MikoPBXConfig(); $this->extensionLength = $config->getGeneralSettings('PBXInternalExtensionLength'); if ($this->needRestart) { // SIGUSR1 пришёл во время init (например, safe.php засчитал нас // мёртвыми по ping'у до того, как listener успел зарегистрироваться). // Мы УЖЕ восстановили обработку — игнорируем этот сигнал, чтобы не // рестартоваться сразу после старта. Реальный shutdown потребует // нового SIGUSR1, который придёт через ping-механизм если воркер // снова перестанет отвечать. $this->logger->writeInfo('Ignoring early SIGUSR1 received during init — listener is now ready, entering main loop.'); $this->needRestart = false; } $this->processState = 'idle'; while ($this->needRestart === false) { $this->processState = 'wait_ami'; $this->am->waitUserEvent(true); $this->processState = 'idle'; if (!is_resource($this->am->socket)) { $this->processState = 'reconnect_ami'; $this->logger->writeError('Need reconnect AMI...'); usleep(100000); $this->am = $this->createAstManager(); $this->setFilter(); $this->am->addEventHandler("userevent", [$this, "callback"]); $this->processState = 'idle'; } $this->logger->rotate(); } } /** * Отправка данных на сервер очередей. * * @param array $result - данные в ормате json для отправки. */ private function Action_SendToBeanstalk(array $result): void { $message_is_sent = false; $error = ''; for ($i = 1; $i <= 10; $i++) { try { $result_send = $this->client->publish(json_encode($result)); if ($result_send === false) { $this->client->reconnect(); } $message_is_sent = ($result_send !== false); if ($message_is_sent === true) { // Проверка break; } } catch (Exception $e) { $this->client = new BeanstalkClient(Bitrix24Integration::B24_INTEGRATION_CHANNEL); $error = $e->getMessage(); } } if ($message_is_sent === false) { $this->logger->writeInfo('Error send data to queue. '.$error); } } /** * Обновление списка номеров для отслеживания. */ private function updateSettings():void{ $this->logger->writeInfo('Update settings...'); $keys_for_del = []; foreach ($this->msg as $key => $time) { if ((time() - $time) > 5) { $keys_for_del[] = $key; } } foreach ($keys_for_del as $key) { unset($this->msg[$key]); } /** @var Extensions $res_ext */ /** @var Extensions $exten */ $this->extensions = []; $res_ext = Extensions::find('type<>"EXTERNAL"'); foreach ($res_ext as $exten) { $this->extensions[] = $exten->number; } /** @var ModuleBitrix24Integration $settings */ $settings = ConnectorDb::invoke(ConnectorDb::FUNC_GET_GENERAL_SETTINGS); if ($settings !== null) { $this->export_records = (intval($settings->export_records) === 1); $this->export_cdr = (intval($settings->export_cdr) === 1); $this->crmCreateLead = (intval($settings->crmCreateLead) !== 0); $this->leadType = (empty($settings->leadType))?Bitrix24Integration::API_LEAD_TYPE_ALL:$settings->leadType; } // Обновляем данные B24 (внутри вызывается b24GetPhones → обновляет кеш inner_numbers) $this->b24->updateSettings($settings); // Читаем inner_numbers из кеша (заполнен b24GetPhones выше). // Если основной кеш пуст/протух (типичный кейс: разовый сбой // user.get → b24GetPhones построил пустую карту в более ранней // ветке без защиты, либо TTL=3600 истёк раньше следующего тика // updateSettings), пробуем долгоживущий снимок inner_numbers_LONG. $innerNumbers = $this->b24->getCache('inner_numbers'); if (!is_array($innerNumbers) || count($innerNumbers) === 0) { $longSnapshot = $this->b24->getCache('inner_numbers_LONG'); if (is_array($longSnapshot) && count($longSnapshot) > 0) { $innerNumbers = $longSnapshot; $this->logger->writeInfo('inner_numbers restored from LONG snapshot. count=' . count($longSnapshot)); } } if (is_array($innerNumbers) && count($innerNumbers) > 0) { $this->inner_numbers = (array)$innerNumbers; } else { $this->logger->writeError('inner numbers is empty. Will retry on next update cycle.'); } if ($settings !== null && !empty($this->inner_numbers)) { $responsible = $this->b24->inner_numbers[$settings->responsibleMissedCalls]??[]; $this->responsibleMissedCalls = empty($responsible)?'':$responsible['ID']; } $this->updateExternalLines(); $this->logger->writeInfo($this->b24->usersSettingsB24, 'usersSettingsB24'); } /** * Обновление массива external_lines для сопоставления внешних линий и номера DID. */ private function updateExternalLines(): void { $this->external_lines = []; $this->disabledDid = []; $lines = ConnectorDb::invoke(ConnectorDb::FUNC_GET_EXTERNAL_LINES, []); foreach ($lines as $line){ $this->external_lines[$line['number']] = $line['number']; if(empty($line['alias'])){ continue; } $aliases = explode(' ', $line['alias']); foreach ($aliases as $alias){ if(empty($alias)){ continue; } $this->external_lines[$alias] = $line['number']; if(intval($line['disabled']) === 1){ $this->disabledDid[] = $alias; } } } } /** * Создаёт подключение к AMI с гарантированным socket timeout. * Использует локальный AsteriskManager вместо core, чтобы waitUserEvent() * не блокировался навечно при смерти Asterisk. * * @return AsteriskManager */ private function createAstManager(): AsteriskManager { $port = PbxSettings::getValueByKey('AMIPort'); $am = new AsteriskManager(); $am->connect("127.0.0.1:$port", null, null, 'on'); return $am; } /** * Установка фильтра * */ private function setFilter():void { $pingTube = $this->makePingTubeName(self::class); $params = ['Operation' => 'Add', 'Filter' => 'UserEvent: '.$pingTube]; $this->am->sendRequestTimeout('Filter', $params); $params = ['Operation' => 'Add', 'Filter' => 'UserEvent: CdrConnector']; $this->am->sendRequestTimeout('Filter', $params); } /** * Функция обработки оповещений. * * @param $parameters */ public function callback($parameters):void{ if ($this->replyOnPingRequest($parameters)){ // Pong через replyOnPingRequest зависит только от $this->am и уже // отправлен выше. Дальше начинается работа на $this->b24 / // updateSettings — её НЕ дёргаем пока воркер в фазе init. // processState='init' выставлен на уровне поля и меняется на // 'idle' только в конце start() — это надёжный маркер // «warming up», см. start(): между регистрацией listener // и завершением new Bitrix24Integration окно несколько секунд. if ($this->processState === 'init') { return; } $this->counter++; // Пинг приходит раз в минуту. Обычный интервал обновления — 5 минут. // Если inner_numbers пуст (токен был невалиден) — обновляем каждый пинг для быстрого восстановления. $updateInterval = empty($this->inner_numbers) ? 1 : 5; if ($this->counter > $updateInterval) { $this->updateSettings(); $this->counter = 0; } return; } if ('CdrConnector' !== $parameters['UserEvent']) { return; } if(!isset($parameters['AgiData'])){ return; } // CDR-event может прийти до окончания инициализации (b24, extensions, // external_lines, extensionLength — всё ставится последовательно // в start()). Обработчики ниже требуют их. Тот же маркер processState. // Asterisk переотправит CDR при следующем звонке; во время первичного // запуска воркера активных звонков обычно нет. if ($this->processState === 'init') { return; } $agiData = $parameters['AgiData']; if (strpos($agiData, 'GZ:') === 0) { $stringData = gzdecode(base64_decode(substr($agiData, 3))); } else { $stringData = base64_decode($agiData); } $data = json_decode($stringData, true); if (!is_array($data) || !isset($data['action'])) { $this->logger->writeInfo($stringData, 'json_decode_error'); return; } $this->logger->writeInfo($stringData, $data['action']); switch ($data['action']) { case 'hangup_chan': $this->actionHangupChan($data); break; case 'dial_create_chan': $this->actionDialCreateChan($data); break; case 'dial_answer': $this->actionDialAnswer($data['id'] ?? '', $data['linkedid'] ?? ''); break; case 'transfer_dial_answer': $this->actionDialAnswer($data['transfer_UNIQUEID'] ?? '', $data['linkedid'] ?? ''); break; case 'dial': case 'transfer_dial': case 'sip_transfer': case 'answer_pickup_create_cdr': case 'transfer_dial_create_cdr': $this->actionDial($data); break; case 'hangup_update_cdr': $this->actionCompleteCdr($data); break; default: break; } } /** * Обработка оповещения о звонке. * * @param $data */ private function actionDial($data):void { $general_src_num = ''; if (($data['transfer'] ?? '0') === '1') { $linkedId = $data['linkedid']??''; // Попробуем выяснить кого переадресуют. $filter = [ '(linkedid = {linkedid})', 'bind' => [ 'linkedid' => $linkedId, ], 'order' => 'id', 'limit' => 1, ]; $filter['miko_result_in_file'] = true; $filter['miko_tmp_db'] = true; $clientBeanstalk = new BeanstalkClient(WorkerCdr::SELECT_CDR_TUBE); $message = $clientBeanstalk->request(json_encode($filter), 2); if ($message !== false) { $filename = json_decode($message, false); if (file_exists($filename)) { $history = json_decode(file_get_contents($filename), false); if (!empty($history)) { // Определим номер того, кого переадресуют. if ($data['src_num'] === $history[0]->src_num) { $general_src_num = $history[0]->dst_num; } else { $general_src_num = $history[0]->src_num; } } unlink($filename); } } else { $this->logger->writeError("Error get data from queue 'select_cdr'". $linkedId); } } $this->actionCreateCdr($data, $general_src_num); } /** * Обработка оповещения о начале телефонного звонка. * * @param $data * @param $general_src_num */ private function actionCreateCdr($data, $general_src_num):void { if ( ! $this->export_cdr) { return; } // originate_start/originate_end не содержат src_chan, src_num и др. — пропускаем $actionExtra = $data['action_extra'] ?? ''; if (strpos($actionExtra, 'originate') !== false) { return; } if($data['action'] === 'dial' && stripos($data['src_chan'] ?? '', '/'.($data['src_num'] ?? '').'-') !== false){ // Это исходящий вызов, начальный канал. $this->outChannels[$data['src_chan']] = [ 'start' => $data['start'], 'UNIQUEID' => $data['UNIQUEID'], 'src_num' => $data['src_num'], ]; } $linkedId = $data['linkedid']??''; $dstNum = Bitrix24Integration::getPhoneIndex($data['dst_num']); $dstUserShotNum = $this->b24->mobile_numbers[$dstNum]['UF_PHONE_INNER']??''; if( !isset($this->b24->usersSettingsB24[$data['dst_num']]) && !isset($this->b24->usersSettingsB24[$data['src_num']]) && !isset($this->b24->usersSettingsB24[$dstUserShotNum])){ // Вызов по этому звонку не следует грузить в b24, внутренний номер не участвует в интеграции. // Или тут нет внутреннего номера. $this->logger->writeInfo("the internal number ($data[src_num] -> $data[dst_num], $dstUserShotNum) is not involved in the integration. $linkedId"); return; } $did = $data['did'] ?? ''; if(in_array($did, $this->disabledDid, true)){ $this->b24->saveCache('finish-cdr-'.$data['UNIQUEID'], true, 3600); $this->b24->saveCache('finish-cdr-'.$data['linkedid'], true, 3600); $this->logger->writeInfo("Integration is disabled for this DID $linkedId"); return; } $LINE_NUMBER = $this->external_lines[$did]??''; $srcIsKnownInternal = isset($this->inner_numbers[$data['src_num']]) || isset($this->b24->usersSettingsB24[$data['src_num']]); if ($srcIsKnownInternal && strlen($general_src_num) <= $this->extensionLength) { $this->logger->writeInfo("This is an outgoing call from an internal number. $linkedId"); if (strlen($data['dst_num']) > $this->extensionLength && ! in_array($data['dst_num'], $this->extensions, true)) { $createLead = ($this->leadType !== Bitrix24Integration::API_LEAD_TYPE_IN && $this->crmCreateLead)?'1':"0"; $req_data = [ 'CALL_START_DATE' => date(\DateTimeInterface::ATOM, strtotime($data['start'])), 'USER_ID' => $this->inner_numbers[$data['src_num']]['ID'] ?? '', 'USER_PHONE_INNER' => $data['src_num'], 'CRM_CREATE' => $createLead, 'DST_USER_CHANNEL' => '', 'PHONE_NUMBER' => $data['dst_num'], 'TYPE' => '1', 'UNIQUEID' => $data['UNIQUEID'], 'linkedid' => $data['linkedid'], 'LINE_NUMBER' => $LINE_NUMBER, 'action' => 'telephonyExternalCallRegister', 'did' => $did ]; $this->Action_SendToBeanstalk($req_data); $this->b24->saveCache('reg-cdr-'.$req_data['linkedid'], true, 600); }else{ $this->logger->writeInfo("{$data['dst_num']} <= $this->extensionLength || {$data['dst_num']} is internal... cancellation $linkedId"); } } elseif (isset($this->inner_numbers[$data['dst_num']]) || isset($this->b24->mobile_numbers[$dstNum]) || isset($this->b24->usersSettingsB24[$data['dst_num']]) ) { if(isset($this->b24->mobile_numbers[$dstNum])){ $userId = $this->b24->mobile_numbers[$dstNum]['ID']; }else{ $userId = $this->inner_numbers[$data['dst_num']]['ID'] ?? ''; } $inner = $data['dst_num']; // dst_num настроен в модуле, но в B24 не привязан к пользователю — // open карточки некому. Register с пустым USER_ID создаёт бесхозную // карточку в B24; в очереди остальные плечи отработают своими dial'ами. if ($userId === '') { $this->logger->writeInfo("Internal $inner is configured in module but not assigned to any B24 user (empty USER_ID). Skip register. $linkedId"); return; } $this->logger->writeInfo("This is an incoming call to an employee's internal number. $linkedId"); $createLead = ($this->leadType !== Bitrix24Integration::API_LEAD_TYPE_OUT && $this->crmCreateLead)?'1':'0'; if (strlen($general_src_num) > $this->extensionLength && ! in_array($general_src_num, $this->extensions, true)) { $this->logger->writeInfo("This is transfer... $linkedId"); $req_data = [ 'UNIQUEID' => $data['UNIQUEID'], 'linkedid' => $data['linkedid'], 'CALL_START_DATE' => date(\DateTimeInterface::ATOM, strtotime($data['start'])), 'USER_ID' => $userId, 'USER_PHONE_INNER' => $inner, 'DST_USER_CHANNEL' => $data['dst_chan']??'', 'PHONE_NUMBER' => $general_src_num, 'TYPE' => '3', 'CRM_CREATE' => $createLead, 'LINE_NUMBER' => $LINE_NUMBER, 'action' => 'telephonyExternalCallRegister', 'did' => $did ]; $this->Action_SendToBeanstalk($req_data); $this->b24->saveCache('reg-cdr-'.$req_data['linkedid'], true, 600); } elseif (strlen($data['src_num']) > $this->extensionLength && ! in_array($data['src_num'], $this->extensions, true)) { // Это вызов с номера клиента. $this->logger->writeInfo("This is incoming... $linkedId"); $req_data = [ 'UNIQUEID' => $data['UNIQUEID'], 'linkedid' => $data['linkedid'], 'CALL_START_DATE' => date(\DateTimeInterface::ATOM, strtotime($data['start'])), 'USER_ID' => $userId, 'USER_PHONE_INNER' => $inner, 'PHONE_NUMBER' => $data['src_num'], 'DST_USER_CHANNEL' => $data['dst_chan']??'', 'CRM_CREATE' => $createLead, 'TYPE' => '2', 'LINE_NUMBER' => $LINE_NUMBER, 'action' => 'telephonyExternalCallRegister', 'did' => $did ]; $this->Action_SendToBeanstalk($req_data); $this->b24->saveCache('reg-cdr-'.$req_data['linkedid'], true, 600); }else{ $this->logger->writeInfo("cancellation... $linkedId"); } } } /** * Скрываем карточку звонка для всех агентов, кто пропустил вызов. * * @param $UNIQUEID * @param $linkedid */ private function actionDialAnswer($UNIQUEID, $linkedid):void { $data = [ 'action' => 'action_dial_answer', 'UNIQUEID' => $UNIQUEID, 'linkedid' => $linkedid, ]; $this->Action_SendToBeanstalk($data); } /** * Обработка завершения звонка. * @param $data */ public function actionHangupChan($data):void { if(empty($data['UNIQUEID'])){ $this->logger->writeInfo($data, "Ignore event, empty UNIQUEID".($data['linkedid'] ?? '')); return; } // Считаем каналы с одинаковым UID $countChannel = $this->channelCounter[$data['UNIQUEID']]??0; $countChannel--; if($countChannel>0){ $this->channelCounter[$data['UNIQUEID']] = $countChannel; }else{ unset($this->channelCounter[$data['UNIQUEID']]); } $outChanData = $this->outChannels[$data['agi_channel']]??[]; unset($this->outChannels[$data['agi_channel']]); // end if(isset($this->channelCounter[$data['UNIQUEID']])){ // Не все каналы с этим ID были завершены. // Вероятно это множественная регистрация. return; } $not_local = (stripos($data['agi_channel'], 'local/') === false); if ($not_local) { $dataForSend = [ 'UNIQUEID' => $data['UNIQUEID'], 'linkedid' => $data['linkedid'] ?? '', 'channel' => $data['agi_channel'], 'action' => 'action_hangup_chan', ]; $this->Action_SendToBeanstalk($dataForSend); } $srsUserId = $this->getInnerNum($data['src_num'] ?? ''); if(!empty($srsUserId) && !empty($outChanData) && ($data['dialstatus'] ?? '') !== 'ANSWERED'){ $this->logger->writeInfo("Send finish event from hangup...".($data['linkedid'] ?? '')); $params = [ 'UNIQUEID' => $outChanData['UNIQUEID'], 'USER_ID' => $srsUserId, 'DURATION' => '0', 'FILE' => '', 'GLOBAL_STATUS' => 'NOANSWER', 'disposition' => 'NOANSWER', "export_records" => $this->export_records, 'linkedid' => $data['linkedid'] ?? '', 'action' => 'telephonyExternalCallFinish', ]; $this->Action_SendToBeanstalk($params); } } /** * Обработка события завершения телефонного звонка. * * @param $data */ private function actionCompleteCdr($data):void { $needReturn = false; $linkedId = $data['linkedid']??''; $did = $data['did'] ?? ''; if(in_array($did,$this->disabledDid, true)){ $this->logger->writeInfo("Integration is disabled for this DID ".$linkedId); $needReturn = true; } $srcNum = $data['src_num'] ?? ''; $dstNum = $data['dst_num'] ?? ''; $uniqueId = $data['UNIQUEID'] ?? ''; $srsUserId = $this->getInnerNum($srcNum); $dstUserId = $this->getInnerNum($dstNum); if(isset($this->channelCounter[$uniqueId]) // Не все каналы с этим ID были завершены. // Вероятно это множественная регистрация. // Либо это CDR по внутреннему вызову. || (!empty($srsUserId) && !empty($dstUserId)) ){ $this->logger->writeInfo("Not all channels with this ID have been completed. This is probably a multiple registration. Or it's CD R on an internal call.. cancellation".$linkedId); $needReturn = true; } if($needReturn === true){ return; } $isOutgoing = false; if (!empty($srsUserId)) { // Это исходящий вызов. $USER_ID = $srsUserId; $isOutgoing = true; } else{ // Это входящие вызов. $USER_ID = $dstUserId; } $responsible = ''; $isMissed = ($data['GLOBAL_STATUS'] ?? '') !== 'ANSWERED'; // Голосовая почта формально ANSWERED, но для CRM это пропущенный // входящий: реального оператора не было — направляем на ответственного // по умолчанию (responsibleMissedCalls), а не на «случайного» пользователя. $isVoicemail = !$isOutgoing && ($dstNum === 'voicemail' || ($data['dst_chan'] ?? '') === 'VOICEMAIL'); if ($isVoicemail) { $isMissed = true; } // Входящий внешний звонок, который не дошёл до сотрудника // (например, IVR/очередь ответили — GLOBAL_STATUS=ANSWERED — но // абонент сбросил до распределения на оператора). Без этой ветки // такие звонки терялись: USER_ID пуст, isMissed=false, и обработчик // уходил в "responsible person was not found". Voicemail исключаем — // он идёт по своему пути, длительность/запись там значимы. $isOrphanIncoming = (!$isOutgoing && !$isVoicemail && empty($USER_ID) && strlen($srcNum) > $this->extensionLength && !in_array($srcNum, $this->extensions, true) && !empty($this->responsibleMissedCalls)); if ($isOrphanIncoming) { $isMissed = true; } $finishKeyID = 'finish-cdr-'.$uniqueId; if(!empty($USER_ID) && !$isMissed) { if (($data['disposition'] ?? '') === 'ANSWERED') { // Вызов был отвечен в рамках этой CDR. $responsible = $USER_ID; } }elseif ($isMissed && $isOutgoing === false && !empty($this->responsibleMissedCalls)) { // Назначаем пропущенный на ответственного. $responsible = $this->responsibleMissedCalls; // Если пропущен, то финишируем только один CDR. $finishKeyID = 'finish-cdr-'.$linkedId; }elseif ($isMissed && !empty($USER_ID) ){ // Рандомно назначаем ответственного для пропущенного. $responsible = $USER_ID; // Дедуп — по linkedid, а не по UNIQUEID: при нескольких NOANSWER- // плечах одного звонка в B24 должна попадать ровно одна карточка // «пропущенного», иначе для очереди из N операторов уехало бы // N missed-карточек. $finishKeyID = 'finish-cdr-'.$linkedId; }else{ // Расширенная диагностика причины: чаще всего сюда попадают // плечи входящих звонков, у которых dst_num — реальный // внутренний номер оператора, но он отсутствует в карте // inner_numbers/mobile_numbers на момент обработки CDR // (см. inner_numbers_diff в IntegrationAMI.log). $this->logger->writeInfo([ 'linkedid' => $linkedId, 'src_num' => $srcNum, 'dst_num' => $dstNum, 'USER_ID' => $USER_ID, 'srsUserId' => $srsUserId, 'dstUserId' => $dstUserId, 'isMissed' => $isMissed, 'isOutgoing' => $isOutgoing, 'isVoicemail' => $isVoicemail, 'isOrphanIncoming' => $isOrphanIncoming, 'GLOBAL_STATUS' => $data['GLOBAL_STATUS'] ?? '', 'disposition' => $data['disposition'] ?? '', 'responsibleMissedCalls' => $this->responsibleMissedCalls, 'inner_numbers_count' => count($this->inner_numbers), 'dst_in_inner_numbers' => isset($this->inner_numbers[$dstNum]), 'src_in_inner_numbers' => isset($this->inner_numbers[$srcNum]), ], 'CancellationContext'); $this->logger->writeInfo("The responsible person was not found. cancellation ".$linkedId); return; } if(!empty($responsible) && !$this->b24->getCache($finishKeyID)){ $LINE_NUMBER = $this->external_lines[$did]??''; $regCacheKey = 'reg-cdr-'.$linkedId; if(!$isOutgoing && strlen($srcNum) > $this->extensionLength && !( $this->b24->getCache($regCacheKey) !== null || isset($this->msg[$regCacheKey]))) { $this->logger->writeInfo($linkedId,"Send Register event... For incoming users only. If it is a missed one, then you need to register it first.".posix_getpid()); $createLead = ($this->leadType !== Bitrix24Integration::API_LEAD_TYPE_OUT && $this->crmCreateLead)?'1':'0'; $req_data = [ 'UNIQUEID' => $uniqueId, 'linkedid' => $linkedId, 'CALL_START_DATE' => date(\DateTimeInterface::ATOM, strtotime($data['start'] ?? '')), 'USER_ID' => $responsible, 'USER_PHONE_INNER' => $dstNum, 'PHONE_NUMBER' => $srcNum, 'DST_USER_CHANNEL' => $data['dst_chan']??'', 'CRM_CREATE' => $createLead, 'TYPE' => '2', 'LINE_NUMBER' => $LINE_NUMBER, 'action' => 'telephonyExternalCallRegister', 'did' => $did ]; $this->Action_SendToBeanstalk($req_data); $this->b24->saveCache($regCacheKey, true, 600); $resSave = $this->b24->getCache($regCacheKey); $this->msg[$regCacheKey] = time(); if($resSave === true){ $this->logger->writeInfo($linkedId, "Save cache reg-cdr-".posix_getpid()); }else{ $this->logger->writeInfo($linkedId, "Fail save cache reg-cdr-".posix_getpid()); } } // Для orphan-incoming реальный оператор не отвечал — IVR/очередь // ответили формально. Принудительно помечаем звонок как пропущенный, // чтобы B24 показал его в "Пропущенных" у ответственного. $globalStatus = $data['GLOBAL_STATUS'] ?? ''; $disposition = $data['disposition'] ?? ''; $billsec = $data['billsec'] ?? '0'; if ($isOrphanIncoming) { $globalStatus = 'NOANSWER'; $disposition = 'NOANSWER'; $billsec = '0'; } $this->logger->writeInfo("Send finish event...".$linkedId); $params = [ 'UNIQUEID' => $uniqueId, 'USER_ID' => $responsible, 'DURATION' => $billsec, 'FILE' => $data['recordingfile'] ?? '', 'GLOBAL_STATUS' => $globalStatus, 'disposition' => $disposition, "export_records" => $this->export_records, 'linkedid' => $linkedId, 'LINE_NUMBER' => $LINE_NUMBER, 'action' => 'telephonyExternalCallFinish', ]; $this->Action_SendToBeanstalk($params); if($isMissed){ $this->b24->saveCache($finishKeyID, true, 60); } } } /** * Событие создания нового канала назначения. * @param $data * @return void */ public function actionDialCreateChan($data):void{ if (empty($data['UNIQUEID'])) { return; } // Считаем каналы с одинаковым UID $countChannel = $this->channelCounter[$data['UNIQUEID']]??0; $countChannel++; $this->channelCounter[$data['UNIQUEID']] = $countChannel; } /** * Является ли номер внутренним. * @param string $number * @return string */ private function getInnerNum(string $number):string { if ($number === '') { return ''; } $number = Bitrix24Integration::getPhoneIndex($number); // После getPhoneIndex нечисловой ввод (например 'voicemail') превращается // в '' — обращение по такому ключу подцепило бы «безномерных» пользователей. if ($number === '') { return ''; } $userId = ''; if(isset($this->inner_numbers[$number])){ $userId = $this->inner_numbers[$number]['ID']; } elseif(isset($this->b24->mobile_numbers[$number])){ $userId = $this->b24->mobile_numbers[$number]['ID']; } return $userId; } } // Start worker process WorkerBitrix24IntegrationAMI::startWorker($argv??[]);