Messageservice 26.200.0: пользовательские шаблоны сообщений через зоны и миграции вместо install.sql
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 в PHPDocbuildBinding()выглядит как['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.