Voximplant 26.800.0: attachRecord и finish отказывают локальным адресам, searchCrmEntities отдаёт один лид
Ломающее обновление
Удалены или изменены публичные API — прикладной код может перестать работать.
В voximplant 26.800.0 у четырёх REST-методов внешней телефонии поменялось поведение, хотя сигнатуры остались прежними. telephony.externalCall.attachRecord и telephony.externalCall.finish с RECORD_URL больше не скачивают записи с приватных адресов и не ходят через прокси. searchCrmEntities отдаёт не больше одного лида, register с EXTERNAL_CALL_ID находит незавершённый дубль без 30-минутного окна. Сильнее всего это ударит по коробке, у которой АТС стоит в той же локальной сети: записи по ссылкам вида http://192.168.x.x/... к звонкам больше не прикрепятся.
Передавайте записи из локальной сети файлом
Когда в telephony.externalCall.attachRecord передан RECORD_URL, работает Rest\Helper::attachRecordWithUrl(). Раньше он качал файл клиентом из HttpClientFactory с настройками http_client_options. Теперь запись качает новый Security\RecordDownloader::downloadToStream(). Он собирает все адреса хоста, A и AAAA, и требует, чтобы каждый был глобально маршрутизируемым. Хватит одного неглобального адреса, и загрузка остановится с ошибкой PRIVATE_IP.
Тем же загрузчиком теперь идёт RECORD_URL в telephony.externalCall.finish. Rest\Helper::finishExternalCall() сразу вызывает CVoxImplantHistory::DownloadAgent(), а агент качает запись через RecordDownloader. Ошибки в ответе finish не будет. Метод отвечает успехом, а запись не прикрепляется.
В коробке это задевает типичную схему, где АТС стоит в локальной сети рядом с порталом и отдаёт ссылку на свой внутренний адрес. После обновления такая запись не прикрепится. То же случится с именем хоста, если DNS, которым пользуется портал, отдаёт для него внутренний адрес.
Если записи остаются в локальной сети, передавайте их в attachRecord файлом: FILENAME и FILE_CONTENT в base64, только wav или mp3. Делайте это после finish и совсем без ключа RECORD_URL. Ветку выбирает isset($params['RECORD_URL']), поэтому даже пустая строка в этом ключе уведёт в загрузку по URL. Если файлом передавать неудобно, выложите записи на хост, у которого все адреса A и AAAA публичные.
Остальные ограничения загрузчика:
- разрешены только
httpиhttps, иначеINVALID_URL, а хост, который не резолвится, даётSERVER_NOT_AVAILABLE; - URL с IPv6-литералом в хосте отклоняется с
IPV6_NOT_SUPPORTED; - редиректы загрузчик обрабатывает сам, до пяти, и каждый новый адрес проверяет заново. Публичная ссылка с редиректом на локальный адрес тоже не пройдёт. На шестом редиректе будет ошибка
REDIRECT; - тело ответа не больше 512 МиБ, таймаут соединения 30 секунд, потока 60 секунд.
Прокси загрузка больше не использует. Клиент создаётся с proxyHost => '' и sendEvents => false, поэтому на него не действуют ни прокси из http_client_options в .settings.php, ни обработчики OnHttpClientBuildRequest. Если портал выходит в интернет только через прокси, запись по URL он всё равно попробует скачать напрямую.
После INVALID_URL, IPV6_NOT_SUPPORTED и EMPTY_PINNED_IP (список RecordDownloader::TERMINAL_ERROR_CODES) агент DownloadAgent() повторную попытку не ставит. PRIVATE_IP в этот список нарочно не включили, и после этой ошибки агент попробует ещё раз через 60 секунд. В лог он пишет, только если включена опция debug, так что без неё о сорвавшейся загрузке после finish в логах ничего не будет.
Публичный адрес можно заранее проверить тем же валидатором, что стоит в загрузчике. Security\PublicUrlValidator::validate() возвращает Result, а при успехе кладёт в данные проверенный адрес, к которому потом привязывается соединение:
<?php declare(strict_types=1);
namespace Vendor\Pbx\Record;
use Bitrix\Main\Loader;
use Bitrix\Voximplant\Security\PublicUrlValidator;
final class RecordUrlCheck
{
/** null, если адрес пройдёт проверку attachRecord */
public function problem(string $recordUrl): ?string
{
Loader::requireModule('voximplant');
$result = PublicUrlValidator::validate($recordUrl);
if ($result->isSuccess()) {
return null;
}
$error = $result->getErrors()[0];
return match ($error->getCode()) {
PublicUrlValidator::ERROR_CODE => 'Хост резолвится в непубличный адрес, запись не прикрепится',
PublicUrlValidator::ERROR_CODE_INVALID_URL => 'Нужна ссылка http или https',
PublicUrlValidator::ERROR_CODE_SERVER_NOT_AVAILABLE => 'Портал не может разрезолвить хост',
default => $error->getMessage(),
};
}
}
Валидатор смотрит только на исходный URL. Редиректы и IPv6-литерал в хосте RecordDownloader проверяет уже во время загрузки, так что пройденная проверка ещё не гарантирует, что файл скачается.
Не ждите от searchCrmEntities всех лидов
Раньше telephony.externalCall.searchCrmEntities (Rest\Helper::searchCrmEntities()) отдавал все лиды, которые нашёл \CCrmSipHelper::findByPhoneNumber(). В ответ попадали и закрытые, с финальной стадией, и те, которые текущий пользователь не может читать. Теперь лид в ответе максимум один. Первый лид из findByPhoneNumber() остаётся, если у него CAN_READ === true и IS_FINAL === false. Иначе приватный findActiveLeadByPhoneNumber() ищет лид по дублям телефона через DuplicateCommunicationCriterion('PHONE', ...) и Factory::getItemsFilteredByPermissions(). Он берёт только лиды с STAGE_SEMANTIC_ID = PhaseSemantics::PROCESS, сортирует по ID и возвращает первый. Если и так ничего не нашлось, лидов в ответе не будет совсем.
Контакты и компании отдаются как раньше. Фильтр по правам добавили только лидам, на CAN_READ у контактов и компаний метод по-прежнему не смотрит.
Интеграция, которая выбирала лид из нескольких или показывала оператору закрытые, после обновления получит другой ответ.
Проверьте, может ли АТС повторить EXTERNAL_CALL_ID
telephony.externalCall.register (Rest\Helper::registerExternalCall()) перед созданием звонка ищет дубль. Совпасть должны пользователь, номер, тип звонка, приложение, линия и EXTERNAL_CALL_ID, если он передан. Раньше дубль искали только среди звонков за последние 30 минут. Теперь при переданном EXTERNAL_CALL_ID срок не ограничен, и вместо нового вернётся незавершённый звонок с теми же полями, даже если ему несколько недель. finish удаляет звонок из таблицы, а агент CallCleaner::deleteOldCalls() чистит звонки старше 30 дней. Без EXTERNAL_CALL_ID окно в 30 минут осталось, а пустая строка в этом поле теперь считается отсутствием поля.
Если АТС может выдать тот же EXTERNAL_CALL_ID повторно тому же пользователю с того же номера и линии, а по старому звонку не вызывали finish, register склеит новый звонок со старым.
Дубль теперь ищется на мастере (useMasterOnly(true)), а сам звонок создаётся под именованной блокировкой vi_ext_call_<md5>. Ключ блокировки собирается из тех же полей. Метод ждёт её до 5 секунд, а получив, проверяет дубль ещё раз. В REST появились две новые ошибки, обе приходят как RestException:
External call registration is already in progress— за 5 секунд блокировку взять не удалось, а дубля так и нет;Could not acquire external call registration lock—lock()бросил исключение.
Если АТС отправит два параллельных register по одному звонку, второй запрос дождётся первого и получит тот же звонок. Ошибку он получит, только если первый не отпустит блокировку за 5 секунд и дубля к этому моменту не будет. Лид первый запрос создаёт уже после снятия блокировки, поэтому во втором ответе CRM_CREATED_LEAD может оказаться пустым. Без EXTERNAL_CALL_ID защита держится на 30-минутном окне: если в CALL_START_DATE передано время старше 30 минут, второй запрос создаст второй звонок.
С CRM_CREATE лид теперь создаётся через registerCallInCrmWithLeadLock(). Если создать его не удалось, LEAD_CREATION_ERROR берётся из CVoxImplantCrmHelper::$lastError, а при пустом значении из сообщений Result. Там может оказаться, например, Voximplant CRM lead registration failed: CALL_ID=...; outcome=lockTimeout; .... Триггер звонка при CRM_CREATE запускается внутри регистрации, если лид создан. Снаружи он стартует только при исходе notRequired, а при alreadyExists, lockTimeout и failed не стартует (shouldStartExternalCallTrigger()).
Переведите свой код на registerCallInCrmWithLeadLock()
CVoxImplantCrmHelper::registerCallInCrm() сохранил сигнатуру, но работает иначе:
registerTouch()и запись созданных сущностей в звонок идут в транзакции БД. Если регистрация не удалась, транзакция откатывается,EntityManagerRegistryзабывает звонок, а сущности и привязки звонка перечитываются с мастера;- запись в историю, трекер каналов и событие
onCallRegisteredInCrmвыполняются после коммита, их исключения только пишутся в лог; - в режиме «только обновление» (исходящий звонок с привязками дела) неуспешный
registerTouch()без ошибок теперь возвращаетtrue, раньше былоfalse.
Сам модуль этот метод больше не вызывает. Роутер (lib/routing/router.php), звонок в нерабочее время (vi_incoming.php), исходящие звонки (vi_outgoing.php, с $startCallTrigger = false), история и REST перешли на registerCallInCrmWithLeadLock(VI\Call $call, bool $onlyCreated = false, $config = null, bool $startCallTrigger = true): Result. Новый метод регистрирует звонок под блокировкой vi_crm_lead_<CALL_ID> с таймаутом 5 секунд (CRM_LEAD_LOCK_TIMEOUT). Перед созданием лида он перечитывает сущности звонка с мастера, и если среди них уже есть созданная (IS_CREATED = Y) или основной лид, второго лида не будет. Если блокировку взять не удалось, метод повторяет ту же проверку и без найденной сущности возвращает lockTimeout.
Исход лежит в getData()['outcome']: created, alreadyExists, notRequired, lockTimeout или failed, это константы CRM_LEAD_OUTCOME_*. Для ошибок заведено десять констант CRM_LEAD_*_ERROR. При created метод сам запускает триггер звонка, если бизнес-процессы стартуют сразу. Если регистрация упала, а у звонка уже были сущности, стартует «восстановительный» триггер по ним. PHPDoc требует вызывать метод вне внешней транзакции.
Если ваш модуль сам регистрирует звонки в CRM, замена выглядит так:
<?php declare(strict_types=1);
namespace Vendor\Telephony\Crm;
use Bitrix\Main\Loader;
use Bitrix\Voximplant\Call;
use CVoxImplantCrmHelper;
use Psr\Log\LoggerInterface;
final class CallLeadRegistrar
{
public function __construct(
private readonly LoggerInterface $logger,
) {}
public function register(string $callId): ?string
{
Loader::requireModule('voximplant');
$call = Call::load($callId);
if (!$call instanceof Call) {
return null;
}
// было: CVoxImplantCrmHelper::registerCallInCrm($call) и bool в ответе
// вызывать вне внешней транзакции, так требует PHPDoc
$result = CVoxImplantCrmHelper::registerCallInCrmWithLeadLock($call);
$outcome = (string)($result->getData()['outcome'] ?? CVoxImplantCrmHelper::CRM_LEAD_OUTCOME_FAILED);
if (!$result->isSuccess()) {
$this->logger->warning('Звонок {callId}: лид не создан, исход {outcome}: {errors}', [
'callId' => $callId,
'outcome' => $outcome,
'errors' => implode('; ', $result->getErrorMessages()),
]);
}
return $outcome;
}
}
VI\Call::reloadCrmEntities() и reloadCrmBindings(), которые перечитывают сущности и привязки из БД без сохранения, помечены @internal, их лучше не трогать. Integration\Crm\EntityManagerRegistry::forget(Call $call) убирает менеджер звонка из статического реестра.
Перепроверьте права телефонии
RoleManager::clearRoleAccess() очищает RoleAccessTable через truncate(). Truncate идёт мимо ORM и не сбрасывает кеш запроса в loadRoleAccess(), а TTL у этого кеша 86400 секунд. Если после очистки в таблицу ничего не добавлялось (например, с экрана прав убрали все привязки), loadRoleAccess() до суток отдавал из кеша старый набор, и отозванные роли продолжали действовать. Когда сохранялась хоть одна привязка, кеш сбрасывал сам RoleAccessTable::add(). Теперь clearRoleAccess() вызывает cleanCache() сразу после truncate.
Компонент voximplant.settings.perms раньше записывал в RoleAccessTable любой ключ из PERMS. Теперь новые коды проходят через Security\AccessCodeFilter. Уже сохранённый код принимается как есть (createForCurrentSet() читает текущий набор до очистки), новый должен пройти \Bitrix\Main\Access\AccessCode::isValid(). Отклонённый код в таблицу не попадает и пишется в лог voximplant.security.accessCodeFilter.
Старый BX.ViPermissionEdit на диалоге access удалили вместе с script.js, script.map.js и script.min.js шаблона. Шаблон теперь подключает расширение voximplant.permissions-selector с классом BX.Voximplant.PermissionsSelector, диалогом на ui.entity-selector. Вкладки диалога и коды доступа, которые они сохраняют:
- сотрудники, только интранет-пользователи:
IU{id}; - подразделения:
DR{id}с подотделами илиD{id}без них; - группы сайта:
G{id}; - группы и проекты:
SG...; - роли структуры и роли компании, если установлены
uiиhumanresources. Из ролей доступны только руководители и заместители (AD,AT), остальные скрыты.
Имена кодов для таблицы прав теперь подбирает Security\AccessCodeLabel::resolveNames(). Сначала он спрашивает CAccess::GetNames(), а коды ролей компании (AD0, AT0 и другие с суффиксом 0) и ролей узлов структуры разбирают провайдеры humanresources UserGroupProvider и StructureRoleProvider.
Проверьте, кому достаются пропущенные звонки
CVoxImplantHistory::detectResponsible() выбирает ответственного за пропущенный звонок. Если в настройках линии TIMEMAN = Y, он теперь учитывает, открыт ли у сотрудника рабочий день в учёте рабочего времени. Из очереди сотрудника выбирает Queue::getFirstUserId(true), а ответственного из CRM метод принимает, только если CVoxImplantUser::GetActiveStatusByTimeman() вернул истину. Если очередь из настроек линии совпадает с очередью звонка, второй раз её не опрашивают.
Подсмотрите защиту от SSRF в RecordDownloader
Модулю, который принимает URL из вебхука или REST, нужна такая же защита, и в lib/security/recorddownloader.php её можно разобрать целиком. downloadToStream(string $url, $stream): Result отдаёт в данных status и httpClient и соединяется с тем адресом, который проверил, без второго DNS-запроса.
Если у ядерного HttpClient есть protected checkRequest(RequestInterface): bool и protected $effectiveIp, загрузчик создаёт анонимного наследника. В checkRequest() тот подставляет effectiveIp = new IpAddress($pinnedIp), а URL, заголовок Host и SNI остаются с исходным хостом. На ядре без этого механизма хост в URL меняется на IP, а Host и ssl.peer_name выставляются вручную. Сигнатуру checkRequest() перед объявлением наследника сверяют через Reflection: несовместимое переопределение даст фатальную ошибку линковки, и try/catch её не поймает.
У HttpClient в ядре уже есть отсечка приватных адресов опцией privateIp => false, но она резолвит только IPv4 (IpAddress::createByUri() в main/lib/Web/HttpClient.php). RecordDownloader явно ставит privateIp => true и проверяет адреса сам. Судя по комментарию, иначе при глобальном http_client_options[privateIp] = false ломались бы домены, у которых есть только AAAA-записи.
PublicUrlValidator переводит IDN-хост в punycode через \CBXPunycode, собирает A-записи через gethostbynamel(), AAAA через dns_get_record() и проверяет каждый адрес filter_var(..., FILTER_FLAG_GLOBAL_RANGE). Комментарий напоминает, что флаг появился в PHP 8.2, и называет эту версию минимальной для проекта.
DNS rebinding RecordDownloader закрывает, а TLS-сертификат по-прежнему не проверяет: клиент создаётся с disableSslVerification => true, как и старый.
Мелочи и находки
- Если логгер
voximplant.crmLeadRegistrationне настроен, сбои регистрации лида пишутся черезFileLoggerвbitrix/modules/voximplant.log.getCrmLeadRegistrationLogger()при этом не смотрит на опциюdebug, хотя обычныйCVoxImplantHistory::WriteToLog()без неё ничего не пишет. - Окно звонка теперь видно поверх слайдеров. Слайдер включает
FocusTrapизui.a11y, а тот ставитinertсоседним элементам, кроме помеченныхdata-a11y-ignore-inert. Этим атрибутом пометили окно звонка, его оверлей и свёрнутый вид, а все попапыphone-callsполучилиzIndexOptions: {alwaysOnTop: true}. Тот же приём пригодится для своего плавающего окна поверх слайдера. - Push-событие
answer_selfуходит со своимidVI_CALL_<CALL_ID>_ANSWER, аtimeoutсVI_CALL_<CALL_ID>_FINISH. Оба по-прежнему отменяют уведомлениеVI_CALL_<CALL_ID>. dialog-loader.tsнарочно не держитui.entity-selectorв зависимостях расширения. Селектор грузится черезRuntime.loadExtension()по первому клику, из модуля импортируются только типы. Комментарий предупреждает, что любой импорт не ради типа вернёт селектор вrelсгенерированногоconfig.php. Роли вне области прав прячет наследникDialogс переопределённымaddItem()(scoped-dialog.ts), потому что провайдеры добавляют элементы и после показа диалога, событий при этом не посылая.- По экрану прав и окну звонка расставлены
data-testid(vox-perms-*,vox-callview-*), а у protected-методов вRest\Helper,CVoxImplantHistory,Routing\RouterиCVoxImplantCrmHelperстоят комментарии «Seam for tests». Похоже, модуль готовят к автотестам. - Судя по диффу, за строкой «Усилена безопасность модуля» в описании обновления стоят три правки из этого разбора: защита загрузки записей от SSRF, проверка кодов доступа при сохранении прав и сброс кеша после очистки ролей.
CVoxImplantCrmHelper::shouldCreateLead()в конце явно возвращаетfalse, раньше там был неявныйnull.- В
CVoxImplantSip::getBuyLink()сменились якоря ссылок на покупку SIP-коннектора: дляru#tab-section-4вместо#tab-section-3, дляkz#tab-section-5вместо#tab-section-3. Уbyостался#tab-section-3. - Картинки
voximplant.phonesи фон баннера лицензии перевели на webp. Вstyle.cssпередimage-set()оставили PNG-фолбэк, аjs/voximplant/common/common.cssссылается только на.webp. - Бит исполнения (100755 → 100644) сняли у
lib/call.php,lib/routing/router.php,lib/security/rolemanager.php,lib/integration/crm/entitymanagerregistry.php,classes/general/vi_sip.phpиclass.phpкомпонента прав. - Deprecated-пометок нет, схема БД не менялась.
Что проверить, если у вас внешняя АТС
- Посмотрите, какой
RECORD_URLАТС передаёт вtelephony.externalCall.attachRecordиtelephony.externalCall.finish. Адрес из локальной сети или имя, которое портал резолвит в такой адрес, после обновления не пройдёт.attachRecordвернёт ошибку, аfinishответит успехом, и запись молча не прикрепится. Публичный адрес прогоните на тестовом портале с 26.800.0 черезPublicUrlValidator::validate(), как в примере выше. - Если сервер записей отвечает редиректом, проверьте каждый адрес цепочки. Редиректов должно быть не больше пяти.
- Если портал ходит наружу через прокси из
http_client_optionsили через обработчикOnHttpClientBuildRequest, убедитесь, что до сервера записей он дотянется напрямую. - Если записи остаются в локальной сети, передавайте их в
attachRecordфайлом, безRECORD_URL.RECORD_URLвtelephony.externalCall.finishтоже не выход:finishExternalCall()сразу вызываетCVoxImplantHistory::DownloadAgent(), а тот качает через тот жеRecordDownloader. Файл (FILENAMEиFILE_CONTENTв base64, только wav или mp3) передавайте послеfinish, совсем без ключаRECORD_URL. - Если интеграция выбирает лид из ответа
searchCrmEntities, прогоните номер, у которого только закрытые лиды или лиды, недоступные пользователю. Лидов в ответе не будет. - Выясните, повторяет ли АТС
EXTERNAL_CALL_IDи вызывает лиfinishпо каждому звонку.registerвернёт незавершённый звонок с тем жеEXTERNAL_CALL_IDвместо нового, без ограничения в 30 минут. - Добавьте в интеграцию повтор
registerпри ошибкахExternal call registration is already in progressиCould not acquire external call registration lock. - Если передаёте
CRM_CREATE, проверьте, что АТС читаетLEAD_CREATION_ERROR. ПриalreadyExistsиlockTimeoutтриггер звонка не запустится, а приfailedможет запуститься только восстановительный, по сущностям, найденным до регистрации.