255 — столько у нас было пар «экран × язык». Полсотни экранов, пять локалей, и каждая строчка текста лежала внутри PHP-класса. Хочешь убрать лишнюю запятую в немецком: ветка, PR, ревью, прогон тестов, деплой.
Человек, который пишет эти тексты, так не умеет. Он пишет в чат «поправьте, пожалуйста» и ждёт два дня.
Мы решили это вылечить очевидным способом: вынести экраны из кода в YAML. По оценке — пара спринтов. По факту — несколько месяцев, потому что бот живой. Вебхук молотит круглосуточно, состояние пользователей лежит в базе, а окна обслуживания у телеграм-бота не бывает в принципе. «Мы на двадцать минут прилягем» означает, что человек в середине разговора жмёт кнопку и не получает ничего.
Проект под NDA, поэтому дальше без имён: названия экранов, тексты и пути выдуманы, продуктовая специфика убрана. Проблемы настоящие, я каждую прочувствовал.
Как это выглядело раньше
Схема для ботов обычная: экран — это класс. Тексты внутри, клавиатура собирается методом.
class BalanceMenu extends Menu
{
protected function texts(): array
{
return [
'ru' => "*Баланс* 💰nnСписаний за сегодня: :spentnЛимит обновится через: :refillIn",
'en' => "*Balance* 💰nnSpent today: :spentnLimits reset in: :refillIn",
'es' => "...",
'fr' => "...",
'de' => "...",
];
}
public function replyMarkup(): InlineKeyboardMarkup
{
return new InlineKeyboardMarkup([
[new InlineKeyboardButton(text: $this->buttons['topup'][$locale], callbackData: 'action:topup')],
$this->controlButtonsRow(),
]);
}
}
Пока экранов десять, это прекрасно: IDE подсказывает, PHPStan ругается, всё типизировано. На пятидесяти начинается другое.
Продуктовая гипотеза «давай переформулируем на трёх экранах» превращается в задачу разработчику. Переводчик в PHP-файл не полезет, а если полезет — сломает экранирование. И главное: карты бота не существует. Кто куда ведёт, знает только тот, кто это писал, и то примерно.
Что хотели получить: экран (текст, медиа, клавиатура) и переходы между экранами становятся данными в репозитории. Код остаётся там, где нужна логика, и больше нигде.
Почему не получилось одним коммитом
Первый план был такой: пишем новый движок, перекладываем в него все экраны, выкатываем в выходные. План прожил до первого обсуждения.
В строке чата у нас лежит указатель на текущий экран и стек навигации для кнопки «Назад». Это сотни тысяч записей в старом формате, и новый код начнёт их читать через секунду после переключения симлинка. Никакого «сначала сконвертируем, потом запустим» — конвертация горячей таблицы это блокировки на минуты.
Вторая засада приятнее по формулировке и неприятнее по последствиям: старые сообщения никуда не деваются. Кнопка, отрисованная три месяца назад, всё ещё живёт в чате. Пользователь листает историю, жмёт — и попадает в обработчик, которого в репозитории уже нет.
Плюс откат несимметричный. Код откатывается за полминуты, данные, которые новый код успел записать — нет.
Поэтому разрезали на фазы, между которыми бот продолжает работать.
Дальше — по порядку то, обо что мы ударились. Половина выглядит очевидной, если читать, а не делать.
1. n в PHP и n в YAML — это разные n
Первый же автоперенос текстов поехал.
В PHP двойные кавычки разворачивают n, и весь код был в двойных кавычках. В YAML двойные кавычки тоже разворачивают, а одинарные — нет. Скрипт переноса часть строк обернул в одинарные, потому что внутри были двойные, и в проде появились сообщения с буквальным nn посреди абзаца.
Лечится тем, что кавычки для многострочных текстов больше не используются вообще:
text:
ru: |-
*Баланс* 💰
Списаний за сегодня: :spent
Лимит обновится через: :refillIn
en: |-
*Balance* 💰
Spent today: :spent
Limits reset in: :refillIn
Про | и |- стоит знать заранее. Первый оставляет перевод строки в конце, второй срезает. В телеге этот n глазом не виден, зато его прекрасно видят снапшот-тесты, которыми мы сверяли старый рендер с новым. Полтораста расхождений на ровном месте, пока не разобрались.
2. YAML не падает. Он молча меняет смысл
Рутину переноса делали скриптом с LLM внутри — пятьдесят классов по пять локалей руками это неделя тоски и опечаток. Текст модель переносит отлично. В отступы попадает как повезёт.
А YAML почти никогда не ругается. Сдвинул ключ на два пробела — он стал вложенным, экран потерял локаль, парсер доволен. Узнаёшь об этом от пользователя, который выбрал немецкий.
Теперь файлы экранов парсятся на CI, а не только в рантайме, и есть тест, который проверяет, что у каждого экрана все пять локалей и одинаковый набор ключей. Но самое полезное — фоллбэк, который был написан ещё до миграции:
if (!array_key_exists($locale, $texts)) {
Log::debug('No translation for text.', [
'locale' => $locale,
'chat_id' => $this->chat->id,
]);
$locale = 'en';
}
Благодаря ему дырка в данных выглядит не как ошибка, а как английский текст у немецкого пользователя. Неприятно, но это не пятисотка в середине оплаты.
3. :refillIn по-испански
В текстах есть подстановки: :spent, :refillIn, дальше обычный str_replace на рендере. Пока они жили в коде, их никто не трогал.
Потом файлы открыл человек с задачей «поправь формулировки», и в испанской локали появилось :gastado. Подстановка не сработала, пользователь увидел в сообщении сырое двоеточие с английским словом. Ничего не упало. Узнали из тикета в поддержку.
Тест, который вытаскивает из каждой локали экрана токены по /:[a-zA-Z]+/ и сравнивает множества между языками, пишется за двадцать минут. Написали, конечно, после.
4. Экранирование поверх экранирования
MarkdownV2 требует экранировать почти всё: . - ! ( ) [ ] { } + = | ~ # >. Пока строка жила в коде, автор сам ставил слэши и сразу видел результат в тестовом боте.
После переезда появился новый тип текста: строка, где часть спецсимволов уже экранирована осознанно (нужна звёздочка как символ), а часть — это разметка (звёздочка как жирный). Наивный проход по таблице превращает * в \*, телеграм отвечает 400, пользователь видит пустоту вместо экрана.
Пришлось делать в два прохода: спрятать уже экранированные последовательности в плейсхолдеры, экранировать остальное, вернуть спрятанное на место.
private const ESCAPE_CHARACTERS = [
'*' => '*', '_' => '_', '[' => '[', ']' => ']',
'(' => '(', ')' => ')', '~' => '~', '`' => '`',
'#' => '#', '+' => '+', '-' => '-', '=' => '=',
'|' => '|', '{' => '{', '}' => '}', '.' => '.',
'!' => '!', '\' => '\\',
];
Если коротко: как только текст стал данными, он перестал быть доверенным. Писать его будет не разработчик.
5. Клавиатура — не всегда данные
Большинство клавиатур ложатся в YAML без вопросов:
keyboard:
allow_back: true
allow_exit: true
buttons:
- - text:
ru: "Пополнить"
en: "Top up"
- - text:
ru: "История списаний"
en: "History"
А потом попадается экран, где кнопка зависит от состояния: у человека с активной подпиской «Продлить», у остальных «Оформить». В коде это был if на две строки.
Сделали два инструмента. Правила прямо в файле:
decisions:
check_subscription:
rules:
- { if: "user.is_subscribed == true", then: "balance_subscribed" }
- { if: "default", then: "balance_regular" }
И люк наружу, когда нужен запрос в базу или поход в платёжку:
keyboard:
builder: "App\Flow\KeyboardBuilders\SubscriptionKeyboard"
Договорились так: если ветвление решает, что показать — это правило в YAML. Если ветвление требует данных, которых в контексте нет — это класс.
Держаться этой границы сложнее, чем кажется. «Ну давай ещё один маленький оператор в условие» — и через полгода у тебя самодельный язык программирования без отладчика, тестов и статического анализа, зато в конфиге. Поэтому в if умеет только сравнение поля контекста с константой, и расширять этот список мы отказываемся принципиально.
6. 64 байта
У инлайн-кнопки callback_data ограничено 64 байтами. Не символами. Пара UUID туда уже не влезает, а нам надо было передавать три идентификатора.
Схема стандартная: в кнопку кладём ссылку, содержимое едет в базу.
$callbackData = TelegramCallbackData::create([
'data' => $parameters, // JSON-колонка
'chat_id' => $this->id,
]);
$button = new InlineKeyboardButton(
text: $button->text,
callbackData: 'action:callback;id:' . $callbackData->id,
);
На нажатии — обратная операция:
case 'callback':
$data = TelegramCallbackData::firstWhere([
'id' => $parameters['id'],
'chat_id' => $this->chat->id,
]);
if (!$data) {
throw new CallbackDataNotFoundException($parameters);
}
$parameters = ['action' => $data->action, ...$data->data];
break;
Дальше начинается интересное. Каждая отрисовка клавиатуры теперь пишет в базу: экран с пятью кнопками — пять INSERT. Эта таблица растёт быстрее, чем таблица сообщений, и требует индекса по (chat_id, id) и чистки по TTL, иначе однажды закончится диск.
И второе, уже про миграцию. Раз старые кнопки живут в чатах месяцами и ссылаются на имена действий в старом формате, эти имена — публичный контракт. Переименовать «заодно, раз уж всё равно переписываем» нельзя. Мы держим таблицу соответствия старых кодов новым ровно столько, сколько живёт TTL, а потом выпиливаем одним коммитом.
7. «Назад» живёт в JSON-колонке
В телеграме нет истории навигации. Её ведёшь ты сам, руками. У нас это стек в строке чата:
public function back(): ?MenuInterface
{
$parameters = ['action' => 'home'];
if (count($this->chat->menu_history ?? []) > 0) {
$history = $this->chat->menu_history;
array_pop($history); // текущий экран
if (count($history) > 0) {
$parameters = array_pop($history); // предыдущий
}
$this->chat->menu_history = $history;
}
return $this->action($parameters);
}
Вот это и есть настоящая миграция данных в такой задаче — не таблицы, а сотни тысяч стеков с именами экранов старого формата, которые новый движок обязан понять в момент деплоя.
Конвертировать миграцией — значит на несколько минут залочить самую горячую таблицу. Почистить — значит у всех, кто прямо сейчас в диалоге, «Назад» выкинет в главное меню. Оба варианта пользователь заметит, поэтому новый движок просто умеет читать оба формата.
Расплата — слой совместимости, который очень легко забыть и оставить в коде навсегда. На него сразу заведена задача с датой удаления, привязанной к TTL из предыдущего пункта.
8. Кэш, который пережил деплой
Файлы экранов кэшируются, парсить YAML на каждый апдейт незачем:
return Cache::remember("menu:yaml:{$file}", null, fn () => Yaml::parseFile($path));
null здесь — «навсегда». Выкатили новые тексты, зашли в бота — старые. Ничего не упало, в логах чисто, в репозитории всё правильно. Полчаса мы искали ошибку в импорте, потому что версия «кэш» в голову приходит последней.
Теперь есть команда и хук, который зовёт её до прогрева конфигов:
before('artisan:optimize', artisan('menu:clear-cache'));
Общий эффект от всей затеи, кстати, вот в этом месте и проявился: деплой перестал быть деплоем кода. В пайплайне рядом стоят две строки, и вторая ничуть не безобиднее первой.
invoke('artisan:migrate'); // схема
invoke('artisan:sync:import'); // контент
9. Заодно вынесли контент из базы в git
Параллельно поехали доменные сущности — те, что раньше редактировались в админке прямо в базе.
Мотив был не только «чтобы правил не разработчик». Прод и стейдж разъезжались: кто, когда и зачем поменял сущность, знала только память коллеги. Ни истории, ни ревью, ни отката.
Импортёр устроен просто и целиком в транзакции:
public function importFromPath(string $path): array
{
$stats = ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'archived' => 0, 'skipped' => 0];
DB::transaction(function () use ($path, &$stats) {
$files = $this->getFilesToProcess($path);
$parsedIds = $this->processFiles($files, $stats);
$stats['archived'] += $this->archiveMissingEntities($parsedIds);
});
return $stats;
}
Решения, которые себя оправдали.
Первичный ключ лежит в самом файле — UUID, а не автоинкремент из базы. Иначе один и тот же файл на стейдже и на проде это две разные сущности, и всё рассыпается на первом же переносе.
Рядом с сущностью хранится md5_file(). Совпал — файл не трогаем вообще. Импорт гоняется на каждом деплое по всем файлам, и без этого фильтра каждый деплой был бы полной перезаписью контента.
Если хэш изменился, содержимое ещё сравнивается по существу. Поправил комментарий, переставил ключи местами — обновляем только хэш, новую версию не плодим:
if ($currentVersion->isIdenticalTo($newVersion)) {
$currentVersion->file_hash = $fileHash;
$stats['unchanged']++;
return;
}
Изменение сущности создаёт новую версию, а не переписывает текущую. Начатые пользовательские сессии продолжают жить на той версии, с которой начинались, иначе деплой посреди дня менял бы контекст сразу у всех активных диалогов. Удалённый из репозитория файл не удаляет строку, а помечает её архивной: на неё ссылаются пользовательские данные, и DELETE тут либо упрётся во внешний ключ, либо утащит историю.
Теперь про то, что сделано плохо. Ошибка в одном файле не роняет импорт: пишем в лог, едем дальше. Для живого прода это правильно. Но счётчика у таких ошибок нет, деплой остаётся зелёным, и испорченный файл превращается в тихо пропавший контент. Ровно та же болезнь, что и с кэшем в предыдущем пункте, и лежит она в бэклоге дольше, чем я готов признать.
10. Чем проверяли, что ничего не развалилось
Двести пятьдесят пять комбинаций глазами не проверяются. Даже двадцать пять не проверяются, если честно.
Сняли снапшоты: на старом коде прогнали рендер каждого экрана в каждой локали, сохранили итоговый текст после подстановок и экранирования плюс структуру клавиатуры — сетку рядов, подписи кнопок, коды действий. Потом то же самое на новом и diff.
Расхождений было под две сотни, настоящих из них — десятка полтора. Остальное те самые хвостовые переводы строки и порядок ключей.
Чего снапшоты не видят: экран отрисован идеально, а кнопка ведёт не туда. Переходы — это граф, и его надо обходить отдельно. Простую часть закрыли тестом (из каждого экрана по каждой кнопке — существует ли экран назначения), остальное прошли руками в стейджинговом боте по списку. Скучно, зато один раз.
Где мы сейчас
Первая фаза в проде: тексты всех экранов — данные, правка формулировки на пяти языках живёт в одном файле и видна в ревью. Клавиатуры в YAML пока описаны у небольшой части экранов, остальные ждут вторую фазу вместе с переходами. Контент едет вместе с кодом и откатывается вместе с ним же.
Если бы начинали заново, поменял бы порядок действий. Снапшоты надо было снять до первой строчки нового кода, а не через две недели после, когда мы уже гадали, что именно поехало и когда. Инвалидацию кэша — в тот же PR, что и кэш. И ошибка обработки файла должна была валить деплой с первого дня, а не лежать в бэклоге полгода.
Чеклист, если собираетесь делать то же самое
-
Снапшоты старого поведения снимаются до того, как написана первая строчка нового.
-
Режьте на фазы. Сначала данные, потом поведение. Между фазами система работает, и это не компромисс, а требование.
-
Многострочные тексты — только блочные скаляры. Кавычки в YAML не то, чем кажутся.
-
Тест на полноту локалей и на совпадение плейсхолдеров между ними. Двадцать минут работы.
-
Фоллбэк на основной язык в рантайме: дырка в данных не должна быть пятисоткой.
-
Текст из данных недоверенный, экранирование обязано пережить уже экранированный ввод.
-
Заранее договоритесь, что описывается декларативно, а что кодом. Иначе конфиг станет языком программирования, и очень быстро.
-
Имена действий и экранов — публичный контракт. В чатах лежат кнопки, отправленные месяцы назад.
-
Состояние пользователей мигрируется совместимостью, а не конвертацией. Это единственный способ обойтись без даунтайма.
-
Слой совместимости заводится сразу с задачей на удаление и датой, иначе он ваш навсегда.
-
Кэш данных чистится в деплое, до прогрева конфигов.
-
Импорт контента — транзакция, идемпотентность, архивирование вместо удаления. И красный деплой, если хоть один файл не импортировался.
Если у вас была похожая миграция и набор ишью не совпал — расскажите в комментариях, на что наступали вы. Особенно интересно, как вы храните навигацию в ботах: я подозреваю, что стек в JSON-колонке не самое изящное решение, просто оно работает.
Автор: i_alakey


