voximplant 26.800.0 Ломающее

Voximplant 26.800.0: attachRecord и finish отказывают локальным адресам, searchCrmEntities отдаёт один лид

4 мин чтения

Ломающее обновление

Удалены или изменены публичные 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 уходит со своим id VI_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-пометок нет, схема БД не менялась.

Что проверить, если у вас внешняя АТС

  1. Посмотрите, какой RECORD_URL АТС передаёт в telephony.externalCall.attachRecord и telephony.externalCall.finish. Адрес из локальной сети или имя, которое портал резолвит в такой адрес, после обновления не пройдёт. attachRecord вернёт ошибку, а finish ответит успехом, и запись молча не прикрепится. Публичный адрес прогоните на тестовом портале с 26.800.0 через PublicUrlValidator::validate(), как в примере выше.
  2. Если сервер записей отвечает редиректом, проверьте каждый адрес цепочки. Редиректов должно быть не больше пяти.
  3. Если портал ходит наружу через прокси из http_client_options или через обработчик OnHttpClientBuildRequest, убедитесь, что до сервера записей он дотянется напрямую.
  4. Если записи остаются в локальной сети, передавайте их в attachRecord файлом, без RECORD_URL. RECORD_URL в telephony.externalCall.finish тоже не выход: finishExternalCall() сразу вызывает CVoxImplantHistory::DownloadAgent(), а тот качает через тот же RecordDownloader. Файл (FILENAME и FILE_CONTENT в base64, только wav или mp3) передавайте после finish, совсем без ключа RECORD_URL.
  5. Если интеграция выбирает лид из ответа searchCrmEntities, прогоните номер, у которого только закрытые лиды или лиды, недоступные пользователю. Лидов в ответе не будет.
  6. Выясните, повторяет ли АТС EXTERNAL_CALL_ID и вызывает ли finish по каждому звонку. register вернёт незавершённый звонок с тем же EXTERNAL_CALL_ID вместо нового, без ограничения в 30 минут.
  7. Добавьте в интеграцию повтор register при ошибках External call registration is already in progress и Could not acquire external call registration lock.
  8. Если передаёте CRM_CREATE, проверьте, что АТС читает LEAD_CREATION_ERROR. При alreadyExists и lockTimeout триггер звонка не запустится, а при failed может запуститься только восстановительный, по сущностям, найденным до регистрации.

Читайте дальше

voximplant 26.700.0 Ломающее Свежее

Voximplant 26.700.0: оценку качества связи убрали, у notifyAdmins() появился тип

Модуль телефонии voximplant 26.700.0 вышел в коробочный Битрикс24 11 сентября 2026 года, по данным официального канала «Битрикс24 changelog». Из карточки звонка убрали оценку качества связи вместе с её JS-событиями, у `Im::notifyAdmins()` появился тип параметра, а публичные константы `SipStatusInfor...

3 мин
timeman 26.100.0 Ломающее Свежее

Timeman 26.100.0: время считается по IANA-зоне сотрудника, у CTimeManEntry и REST другие значения

С timeman 26.100.0 учёт рабочего времени берёт зону сотрудника из IANA-идентификатора в `b_user.TIME_ZONE` и считает смещение UTC на момент каждого события, с переходами на летнее и зимнее время. Форматы полей и ответов остались прежними, поменялись значения: `TIME_START`, `TIME_FINISH` и `DURATION`...

3 мин
report 26.200.100 Безопасность Свежее

Report 26.200.100: закрыта SQL-инъекция через формат имени пользователя

Релиз «Конструктора отчётов» вендор описал фразой «Усилена безопасность модуля». На деле изменилась одна строка в `CReport::getFormattedNameExpr()`, теперь метод экранирует литералы из формата имени пользователя перед вставкой в SQL. Если модуль `report` установлен, обновите его до 26.200.100. Файл...

2 мин
note 26.400.0 Ломающее Свежее

note 26.400.0: updatedAt не отражает правки в редакторе, уведомления выключены по умолчанию

В [note 26.300.0](/bitrix-updates/note/note-26-300-0) модуль «Базы знаний 2.0» получил события, историю версий и подписки. Версия 26.400.0 добавляет избранное, обратные ссылки между документами, отдельную материализацию markdown-проекции Yjs-документа, асинхронный пересчёт поиска и боковой чат Bitri...

2 мин
Мы используем файлы cookie для улучшения работы сайта. Продолжая использовать сайт, вы соглашаетесь с нашей политикой конфиденциальности.
AI Домовой

AI Домовой История

на связи

пишет…
Нет истории чатов
AI Домовой

Нужна авторизация

Войдите, чтобы задавать вопросы AI Домовому.

Войти