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 мин
timeman 26.350.0 Заметное Свежее

Timeman 26.350.0: план дня пишется в нужную запись, блоки БП возвращают Saved

В timeman 26.350.0 для коробочного Битрикс24 блоки «Сохранить план на день» и «Сохранить отчёт сотрудника за день» из нового конструктора бизнес-процессов научились писать в заданную запись рабочего дня, оставлять Markdown как есть и сообщать, сохранилось ли (у отчёта новые настройки в конструкторе,…

2 мин
rpa 26.300.0 Безопасность Свежее

Rpa 26.300.0: права на перемещение элементов наконец проверяются

До RPA 26.300.0 `UserPermissions::canMoveItem()` сразу отвечал `true`, и любой сотрудник, кроме экстранет-пользователей, мог перетащить любой элемент на любую стадию, а права стадии на перемещение не учитывались. Теперь права проверяются, а тем, кто может только смотреть, `rpa.item.get` и `rpa.item….

3 мин
booking 26.500.100 Заметное Свежее

Booking 26.500.100: addList отвечает ошибкой, когда не создал ни одной брони, а четыре колонки дат стали DATETIME

В коробочный Битрикс24 пришёл booking 26.500.100 с двумя правками. AJAX-экшен `booking.api_v1.Booking.addList` теперь возвращает ошибку, если не создал ни одной брони. Апдейтер переводит четыре колонки дат с `TIMESTAMP` на `DATETIME`, и в `install/migrations/tables.php` они объявлены через `datetime…

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

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

на связи

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

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

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

Войти