Messageservice 26.200.0: пользовательские шаблоны сообщений через зоны и миграции вместо install.sql

3 мин чтения
Узнавайте о ломающих изменениях Битрикса раньше, чем они доедут до прода: свежие разборы — в канале @bxmax_updates

Changelog называет messageservice 26.200.0 технической подготовкой к шаблонам сообщений в CRM. В диффе от 26.100.0 (разбор) почти 11 тысяч строк, и это подсистема пользовательских шаблонов: таблица, команды, реестр зон, JSON-контроллер, грид и селектор в редакторе сообщений. В коробке она выключена, потому что ни один модуль не объявил зону, а свою зону можно подключить из модуля в /local/modules.

Сразу проверить свой код стоит тем, кто накатывал схему модуля скриптами из install/db/*.sql. Этих файлов больше нет, модуль ставится декларативными миграциями.

Что сломается

Уберите install.sql из своих скриптов

Из install/db/mysql/ и install/db/pgsql/ удалены install.sql и uninstall.sql. Ставьте модуль его установщиком, CModule::CreateModuleObject('messageservice')->InstallDB(). Схему для сверки берите из install/migrations/tables.php. Там через Migration::table()->create(...) описаны семь прежних таблиц (b_messageservice_message, _rest_app, _rest_app_lang, _incoming_message, _restriction, _channel, _template), и колонки с именами индексов совпадают с MySQL-версией удалённых файлов.

InstallDB() вместо $DB->RunSQLBatch(...) вызывает $this->installMigrations() из CModule. Проверка TableExists('b_messageservice_message') из него ушла. Существующие таблицы теперь пропускает сам Migration::table()->create(), по каждой таблице отдельно. UnInstallDB() вызывает uninstallMigrations($dropTables). Таблицы и опции удаляются, только если передан savedata со значением не Y.

Обработчики событий и агенты переехали из InstallDB() и UnInstallDB() в install/migrations/events.php и install/migrations/agents.php. Состав прежний: main:OnAfterEpilog, три события rest:*, два события imconnector:* для Wazzup, агенты Queue::cleanUpAgent и IncomingMessage::cleanUpAgent с интервалом 86400 секунд.

Рядом появился migration_config.json. С ним CUpdater::canRunUpdater() из main/classes/general/update_class.php разрешает запускать updater.php модуля в режиме DATABASE_DDL. Сам updater.php в дифф не попал, поэтому как новая таблица появится на уже работающих порталах, по диффу не видно.

Новое

Заведите свою зону шаблонов

Каждый шаблон привязан к тройке TemplateBinding: зона, сцена и targetId. Зона отвечает за права и подписи в интерфейсе, в коде это наследник Bitrix\MessageService\Public\Provider\CustomTemplate\Zone\AbstractZoneProvider. В его PHPDoc как примеры зон названы CRM, booking и calendar. В эталоне bus модуля crm нет, и секцию messageservice.custom-template не объявляет ни один модуль. Поэтому компонент bitrix:messageservice.custom-template.list отдаёт ACCESS_DENIED, а селектор шаблонов в редакторе сообщений скрыт.

Зоны собирает CustomTemplateZoneRegistry::load(). Он обходит ModuleManager::getInstalledModules() и у каждого модуля читает секцию messageservice.custom-template из .settings.php через Configuration::getInstance($moduleId). Тот ищет файл через Loader::getLocal, так что подойдёт и модуль из /local/modules. Провайдер реестр берёт из ServiceLocator, а сам реестр зарегистрирован синглтоном с фабрикой load(). Секцию в своём модуле кладите в value, как services и controllers, потому что Configuration::get() возвращает содержимое именно этого ключа. Пример в PHPDoc CustomTemplateZoneRegistry написан без value, и зона по нему не подхватится. ID зоны реестр берёт из ключа в zones, а getId() в этом релизе не вызывается, так что держите их одинаковыми:

        <?php declare(strict_types=1);

// /local/modules/vendor.tickets/.settings.php
return [
    'messageservice.custom-template' => [
        'value' => [
            'zones' => [
                'tickets' => [
                    'provider' => \Vendor\Tickets\Integration\MessageService\TicketsZoneProvider::class,
                ],
            ],
        ],
        'readonly' => true,
    ],
];

    

Провайдер обязан реализовать getId(), buildBinding(), describeBinding(), fillSelectorItem(), canManageTemplates(), getReadableScope() и getReadableFilterOptions(). Права на чтение, создание, правку и удаление (canReadTemplates(), canAddTemplate(), canUpdateTemplate(), canDeleteTemplate()) по умолчанию сводятся к canManageTemplates(), а isValidScene() возвращает true. Набросок зоны для своих тикетов, где шаблоны привязаны к очереди. Читать их может участник очереди, создавать и править только администратор.

        <?php declare(strict_types=1);

namespace Vendor\Tickets\Integration\MessageService;

use Bitrix\MessageService\Public\Dto\CustomTemplate\CustomTemplateSelectorItem;
use Bitrix\MessageService\Public\Provider\CustomTemplate\Zone\AbstractZoneProvider;
use Bitrix\MessageService\Public\Type\CustomTemplate\BindingDescription;
use Bitrix\MessageService\Public\Type\CustomTemplate\FilterSceneOption;
use Bitrix\MessageService\Public\Type\CustomTemplate\FilterSubjectOption;
use Bitrix\MessageService\Public\Type\CustomTemplate\ReadableFilterOptions;
use Bitrix\MessageService\Public\Type\CustomTemplate\ReadableScope;
use Bitrix\MessageService\Public\Type\CustomTemplate\SelectorBadge;
use Bitrix\MessageService\Public\Type\CustomTemplate\TemplateBinding;
use Vendor\Tickets\Application\Access\QueueAccess;

final class TicketsZoneProvider extends AbstractZoneProvider
{
    public const ZONE_ID = 'tickets';
    public const SCENE_REPLY = 'reply';

    // QueueAccess — свой сервис модуля: getReadableQueues() отдаёт [id => название], админу все очереди.
    // ServiceLocator соберёт его по типу параметра, регистрировать в services не нужно, если конструктор QueueAccess тоже собирается по типам.
    public function __construct(private readonly QueueAccess $access)
    {
    }

    public function getId(): string
    {
        return self::ZONE_ID;
    }

    public function isValidScene(string $scene): bool
    {
        return $scene === self::SCENE_REPLY;
    }

    public function buildBinding(string $scene, array $sceneRawContext): TemplateBinding
    {
        $queueId = (int)($sceneRawContext['queueId'] ?? 0);

        return new TemplateBinding(self::ZONE_ID, $scene, (string)$queueId);
    }

    public function describeBinding(TemplateBinding $binding): BindingDescription
    {
        return new BindingDescription(
            sceneLabel: 'Ответ в тикете',
            targetLabel: $this->access->getQueueName((int)$binding->targetId),
        );
    }

    public function fillSelectorItem(
        CustomTemplateSelectorItem $item,
        TemplateBinding $currentBinding,
    ): CustomTemplateSelectorItem {
        if ($item->binding->targetId === $currentBinding->targetId) {
            return $item;
        }

        $queueName = $this->access->getQueueName((int)$item->binding->targetId);

        return $item->withBadges([new SelectorBadge($queueName)]);
    }

    public function canManageTemplates(int $userId): bool
    {
        return $this->access->isAdmin($userId);
    }

    public function canReadTemplates(int $userId, TemplateBinding $target): bool
    {
        return $this->access->canReadQueue($userId, (int)$target->targetId);
    }

    public function getReadableScope(int $userId): ReadableScope
    {
        if ($this->access->isAdmin($userId)) {
            return ReadableScope::all();
        }

        $queueIds = array_keys($this->access->getReadableQueues($userId));
        if ($queueIds === []) {
            return ReadableScope::none();
        }

        return ReadableScope::targets(array_map(strval(...), $queueIds));
    }

    public function getReadableFilterOptions(int $userId): ReadableFilterOptions
    {
        $subjects = [];
        foreach ($this->access->getReadableQueues($userId) as $queueId => $queueName) {
            $subjects[] = new FilterSubjectOption((string)$queueId, $queueName);
        }

        return new ReadableFilterOptions(
            scenes: [new FilterSceneOption(self::SCENE_REPLY, 'Ответ в тикете')],
            subjects: $subjects,
        );
    }
}

    

getReadableScope() описывает, какие привязки пользователь может читать, а SQL-условие из него messageservice строит сам. Вариантов четыре: all(), none(), targets() со списком targetId и bindings() со списком привязок. Селектор шаблонов собран на entity-selector, сущность называется messageservice-custom-template. Среди недавних (recent) он показывает шаблоны текущей привязки, остальное добирает из зоны и ищет по TITLE и BODY. Бейдж из fillSelectorItem() попадает в Item::addBadges() через SelectorBadge::toArray().

Чтобы селектор появился в редакторе, сцена должна вернуть привязку из Scene::buildTemplateBinding(Context $context): ?TemplateBinding. Метод новый и по умолчанию возвращает null, так что существующие наследники Scene работают как раньше. Ещё нужен флаг Layout::setCustomTemplateSelectorShown(true), а для создания шаблона прямо из селектора setCustomTemplateCreateInSelectorShown(true). Оба по умолчанию выключены.

Кто разбирает JSON редактора у себя, увидит в scene новый ключ templateBinding. Editor::jsonSerialize() теперь собирает scene из Scene::jsonSerialize() и добавляет привязку {zoneId, sceneId, targetId} или null.

Откройте форму шаблона сами

Форму создания и редактирования messageservice сам не открывает. В гриде bitrix:messageservice.custom-template.list (параметры ZONE, SCENE, TARGET_ID) кнопки «Создать» и «Редактировать» только эмитят BX.MessageService.CustomTemplate.List:onCreateRequested и :onEditRequested, и в эталоне на них никто не подписан. Редактор сообщений шлёт CustomTemplate:onFormRequested и CustomTemplate:onListRequested. Слушать эти события и открывать форму должен модуль зоны. Для модалки есть расширение messageservice.custom-template.editor: CustomTemplateEditor построен на ui.system.dialog и бросает событие onSlotRender, чтобы в модалку встроили редактор, а CustomTemplateService вызывает экшены контроллера.

Событие onTemplatePlaceholderInsert отменяемое. Оно приходит с данными { placeholder, existingPlaceholders, binding }, и зона вырезает плейсхолдер вызовом preventDefault().

Имена экшенов messageservice.api.CustomTemplate.CustomTemplate.create, .update и .delete получились из новой записи namespaces в .settings.php модуля, она даёт контроллерам из Infrastructure\Controller префикс api. createAction принимает zone, scene, targetId, title, body и strategy (по умолчанию reject). Права контроллер спрашивает у вашей зоны через can*Template(), а isValidScene() вызывает только при создании. configureActions() в контроллере не переопределён, так что действуют фильтры по умолчанию.

Если в сообщение вставили пользовательский шаблон, после попытки отправить редактор предлагает тостом «Обновить шаблон »...«?», когда текст шаблона текущей привязки правили, или «Сохранить шаблон »...«?», когда шаблон взят из чужой привязки. Без кнопки ничего не сохраняется. «Да, обновить» обновляет шаблон, а «Да, сохранить» создаёт копию со стратегией autoSuffix. Кнопка «Больше не спрашивать» пишет флаг в Preferences. У конструктора Preferences появился необязательный третий параметр public bool $saveFlowOptOut = false, а ключ saveFlowOptOut добавлен в fromArray() и jsonSerialize().

Проверяйте права до вызова команды

Из PHP шаблоны создают команды в lib/Public/Command/CustomTemplate/, они построены на Bitrix\Main\Command\AbstractCommand. Это CreateCustomTemplateCommand(binding, template, userId, OnTitleDuplicate $strategy = Reject), UpdateCustomTemplateCommand(templateId, template, userId) и DeleteCustomTemplateCommand(templateId, userId). Enum OnTitleDuplicate принимает reject или autoSuffix, результат CustomTemplateResult отдаёт getTemplate(): ?CustomTemplateDetails. Права команды не проверяют, это делает контроллер. Если создаёте шаблон из своего кода, сначала спросите canAddTemplate() у зоны. Читать шаблоны можно через Provider\CustomTemplate\CustomTemplateProvider: getById, getByIds, getForSelector, getForGrid, getCountForGrid.

Шаблоны удалённых сущностей тоже убирает владелец зоны. Для этого зарегистрирован сервис messageservice.customTemplate.zoneCleanupService с методами cleanupZone, cleanupByBinding, cleanupByTarget и cleanupByTargetPrefix.

БД

Новая таблица b_messageservice_custom_template: ID, ZONE varchar(32), SCENE varchar(32), TARGET_ID varchar(255), TITLE varchar(255), BODY longtext, DATE_CREATE с CURRENT_TIMESTAMP по умолчанию, AUTHOR_ID, DATE_MODIFY и MODIFIED_BY. UNIQUE-индекс ux_messageservice_custom_template_zone_scene_target_id_title стоит на (ZONE, SCENE, TARGET_ID, TITLE), так что два шаблона с одним названием в одной привязке не сохранятся. Идентификаторы своей зоны и сцен укладывайте в 32 символа.

Лимит шаблонов на зону задаёт новая опция messageservice:custom_template_zone_limit, по умолчанию 500.

TITLE и BODY ORM-класс прогоняет через Emoji::encode() при записи и Emoji::decode() при чтении. В customtemplatetable.php прямо написано, что соединение с БД работает в utf8mb3, и 4-байтовые эмодзи превращались бы в ?. Emoji::encode() трогает только 4-байтовые символы: 😀 лежит в таблице как :f09f9880: (восемь hex-цифр его UTF-8 байтов), остальной текст хранится как есть. Запрос мимо ORM вернёт эмодзи в этом виде. LIKE-поиск в репозитории и в TemplateFilter кодирует строку запроса тем же Emoji::encode(), поэтому находит токены в таблице и не ловит «illegal mix of collations» на соединении utf8mb3.

Мелочи и находки

  • Повтор названия отсекает индекс, без SELECT перед INSERT и гонки между ними. Обработчики команд ловят Bitrix\Main\DB\DuplicateEntryException, а NameConflictResolver::tryWithAutoSuffix() идёт по цепочке getPrevious() и пробует до 100 названий вида «Title (2)», «Title (3)», обрезая их до 255 символов. Такую схему можно перенести в свой справочник.
  • Лимит на зону мягкий. В CreateCustomTemplateCommandHandler.php объяснено, что транзакционную блокировку не ставили сознательно, потому что редкий перебор на один шаблон при конкурентном создании допустим.
  • Пустой ReadableScope::none() грид превращает в фильтр ID = 0. Положительный автоинкремент с ним никогда не совпадёт, и подменённый фильтр не выдаст, сколько шаблонов в зоне.
  • deleteAllByZoneAndTargetPrefix экранирует %, _ и \ и ищет по LIKE 'prefix%'. Комментарий рядом поясняет, что префикс с двоеточием вроде '20:' не захватит '200:'. Похоже, это подсказка о формате TARGET_ID будущей CRM-зоны. Контекст CRM в PHPDoc buildBinding() выглядит как ['entityTypeId', 'entityId', 'categoryId']. Если в своей зоне будете звать cleanupByTargetPrefix(), заканчивайте префикс разделителем.
  • В комментариях CustomTemplateRowAssembler.php и CustomTemplateTitleFieldAssembler.php расписано, что main.ui.grid выводит значения колонок как сырой HTML. TITLE и превью проходят HtmlFilter::encode, а строка для onclick сначала CUtil::JSEscape, потом ещё раз кодируется для атрибута. В своих гридах кодируйте так же.
  • ID грида собирается как messageservice_custom_template_list_<zone>_md5(scene:target). По комментарию в CustomTemplateGridSettings.php, так карточки лида и контакта не делят сохранённый фильтр.
  • В CustomTemplateUserFieldAssembler.php ссылка на профиль автора зашита как PROFILE_URL = '/company/personal/user/', это путь Битрикс24. Фраза об отказе в доступе тоже отправляет к «администратору вашего Битрикс24».
  • Валидация не пропускает тело длиннее 4000 символов (BODY_MAX_LENGTH), а колонка BODY в базе longtext.
  • В JS-тип Layout добавили поле contentMarginBottom, а в PHP-классе Layout его нет, с сервера его пока не задать.
  • Из editor-header.js удалён комментарий // todo templates for custom text, и рядом с селектором шаблонов каналов встал CustomTemplateSelector.

Новая версия вышла — разбор уже в Telegram

В канале @bxmax_updates публикуем каждый свежий разбор: что сломается, что устарело и что появилось в API. Узнаете до того, как обновите прод.

Подписаться

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

messageservice 26.100.0: публичный UI-слой редактора сообщений и селектора каналов

Крупный релиз почти без ломающих изменений: модуль обзавёлся публичным `Public\UI` — фабрикой, из которой любой модуль собирает редактор сообщения, селектор канала или слайдер подключений. Раньше каждый потребитель (CRM, рассылки, роботы бизнес-процессов) рисовал этот интерфейс сам.

3 мин
ui 26.693.0 Заметное Свежее

UI 26.693.0: загрузчик называет причину отказа, а вьювер прячет кнопку скачивания

В ui 26.693.0 загрузчик файлов перед сохранением ещё раз проверяет временный файл и при отказе отдаёт текст ошибки из `CFile::CheckFile`, а при превышении дисковой квоты кладёт в ошибку причину `disk_quota_exceeded`. Вьювер теперь прячет кнопку скачивания у отдельного файла, а `FeaturePromoter.show(…

3 мин
socialnetwork 26.250.0 Рутинное Свежее

Socialnetwork 26.250.0: комментарий о том, как дочерние чаты проекта попадают в папку «Все»

В socialnetwork 26.250.0 код не менялся, проверять после обновления нечего. В двух внутренних обработчиках, `SendChildAttachedMessage` и `SendChildDetachedMessage`, появился одинаковый docblock о том, как дочерний чат проекта попадает в папку «Все» мессенджера. Push `chatUpdate` только убирает строк…

1 мин
rest 26.900.0 Заметное Свежее

Rest 26.900.0: встройки без приложения, чтение приложений без AppTable и заготовка MCP

В rest 26.900.0 у встроек появился владелец, и модуль может повесить встройку на свою сущность без REST-приложения. Приложение читается в типизированный DTO через `ApplicationProvider::find*()`, статус оплаты считает `ApplicationStatusCalculator`, а для REST 3.0 заложен мост в MCP, который в БУС не…

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

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

на связи

пишет…

Можно закрыть чат и перейти на другую страницу. Когда ответ будет готов, на кнопке Домового появится отметка.

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

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

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

Войти