Бот для мероприятия: регистрация на Holistic4u Summit
Собрать регистрацию на живое мероприятие за несколько дней - без форм, без редиректов и с мгновенными напоминаниями в мессенджер - задача, которая раньше казалась мне головной болью. Для проекта Holistic4u Summit мы решили её через бот для мероприятия прямо в Telegram - и это оказалось неожиданно элегантно. Саммит о биохакинге, холистическом здоровье и будущем индустрии велнеса проходил 29-31 июля в онлайн-формате и оффлайн в Москве на ВДНХ.
В этой статье расскажу, как устроен бот изнутри, какие грабли мы собрали на этапе code review перед боевой рассылкой, и как прикрутили аналитическую воронку прямо в HTML-админку.
Контекст: зачем бот, а не форма
Проект h4b-landing — это Next.js-лендинг для Holistic4u. Лендинг отдаёт статику, собирает лиды, но для регистрации нужен был живой диалог: имя, телефон, email, подтверждение формата участия (онлайн или оффлайн). Классическая форма на сайте требует либо стороннего сервиса рассылок, либо собственного бэкенда с очередями. Telegram-бот решает обе проблемы разом: пользователь регистрируется в привычном интерфейсе, а напоминания о трансляции приходят туда же, без email-спама.
Архитектурно бот живёт в поддиректории src-bot/ того же монорепо:
index.ts— точка входа, поднимает HTTP API для админки на порту 3097bot.ts— 556 строк диалога регистрации, конечный автомат состоянийdb.ts— обёртка над SQLite (черезbun:sqlite), 339 строкscheduled-checker.ts— планировщик отложенных уведомлений, пинг каждые 30 секунд
База данных — SQLite-файл src-bot/data/h4b_bot.db. Схема описана в bot_schema.sql: таблицы users (telegram_id, name, phone, email, статусная машина), bot_messages (редактируемые тексты), admin_tokens (токены с TTL), broadcast_log.
Сервис запущен через systemd как h4b-bot.service на Bun 1.3.14. Статическая HTML-админка (public/admin-bot.html) отдаётся лендингом и авторизуется по токену из таблицы admin_tokens.
Проблема 1: баг с сохранением сообщений в админке
Первое, с чем столкнулись — редактирование текстов бота в UI не сохранялось. Открываешь вкладку сообщений, правишь приветствие, жмёшь «Сохранить» — после перезагрузки страницы всё возвращается к старому значению.
Причина оказалась в seed-функции: при каждом старте сервиса db.ts прогонял инициализацию таблицы bot_messages с дефолтными значениями, перезаписывая то, что пользователь сохранил через API. Классика — seed не проверял INSERT OR IGNORE, а делал INSERT OR REPLACE.
После фикса seed стал использовать INSERT OR IGNORE, и пользовательские правки начали переживать рестарты сервиса.
-- было (перезаписывало при каждом старте)
INSERT OR REPLACE INTO bot_messages (key, value) VALUES ('welcome', ?);
-- стало (не трогает существующие записи)
INSERT OR IGNORE INTO bot_messages (key, value) VALUES ('welcome', ?);Проблема 2: смена токена бота и миграция пользователей
В какой-то момент потребовалось перевести бота на новый токен — новый аккаунт @Holistic4u_bot. Процедура простая: обновляем BOT_TOKEN в .env-bot, перезапускаем сервис. Но есть нюанс: существующие пользователи были зарегистрированы через старый бот. Их telegram_id остаётся тем же, но webhook и polling теперь идут через новый токен.
Мы сделали бэкап .env-bot.bak-2026-07-27 перед сменой, проверили getMe и getWebhookInfo после рестарта — всё поднялось корректно. Сервис слушает порт 3097, allowed_updates выставлены нашим стартом.
Одновременно оформили профиль бота через Bot API:
- Имя: Holistic4u Summit
- Краткое описание: «Регистрация на саммит Holistic4u: 29–31 июля, онлайн + оффлайн в Москве»
- Аватар: логотип 512×512, загружен через
setMyProfilePhoto - Описание в пустом чате: текст с датами, форматами участия и ссылками на юридические документы
Важный момент про UX: в Telegram есть два разных «описания» бота. Первое — setMyShortDescription — видно в профиле и в пустом чате до нажатия Start. Второе — setMyDescription — показывается в пустом чате как подсказка. Приветственное сообщение с дисклеймером приходит уже после нажатия кнопки Start, потому что до этого бот технически не может отправить сообщение пользователю.
Поэтому дисклеймер о политике конфиденциальности мы разместили в двух местах: краткую ссылку — в описании до Start (некликабельно, ограничение Telegram), полный текст с кликабельными ссылками — в первом сообщении после Start.
⏱️ Регистрация займёт меньше минуты: имя, телефон, email.
Ссылку на трансляцию и напоминания пришлём прямо в этот чат.
Нажимая кнопку ниже, вы принимаете условия:
• Политика конфиденциальности
• Оферта
• Согласие на обработку персональных данных
Проблема 3: баг с форматированием сообщений (Markdown vs HTML)
После смены токена бот упал при первом же /funnel-запросе. Причина: сообщение собиралось в Markdown-разметке, но отправлялось с параметром parse_mode: 'HTML'. Telegram пытался распарсить **жирный** как HTML и падал с ошибкой 400.
Фикс — привести все шаблоны к единому parse_mode. Мы выбрали HTML как более предсказуемый для сложных сообщений с ссылками:
// было — смешанная разметка
await bot.sendMessage(chatId, `**Привет!** Добро пожаловать`, {
parse_mode: 'Markdown'
});
// стало — единый HTML
await bot.sendMessage(chatId, `<b>Привет!</b> Добро пожаловать`, {
parse_mode: 'HTML'
});Code Review перед боевой рассылкой
Перед запуском рассылки по всей базе провели жёсткий code review четырёх файлов src-bot/. Задача — найти дефекты, которые проявятся именно при массовой отправке, а не в тестовом режиме.
Топ-3 риска, которые нашли
1. sendRichMessage — комбинации медиа и текста. Функция отправки сообщений с медиа не обрабатывала все комбинации: текст без медиа, одно фото, одно видео, несколько медиафайлов. При рассылке с вложением и длинным caption Telegram обрезает caption до 1024 символов для фото/видео (против 4096 для обычного текста). Это не было учтено — длинные описания молча обрезались.
Решение: добавили MAX_TEXT константу, функцию sendLongText для разбивки длинных сообщений, и validateMedia для проверки типа и размера вложений перед отправкой.
2. Кэш file_id. При рассылке одного и того же медиафайла тысяче пользователей каждый раз делался upload файла заново. Telegram рекомендует кэшировать file_id после первой успешной отправки и переиспользовать его. Добавили fileIdCache — простой Map в памяти, который живёт на время сессии сервиса.
const fileIdCache = new Map<string, string>();
async function sendWithCachedFileId(chatId: number, localPath: string) {
const cached = fileIdCache.get(localPath);
if (cached) {
return bot.sendPhoto(chatId, cached);
}
const result = await bot.sendPhoto(chatId, fs.createReadStream(localPath));
fileIdCache.set(localPath, result.photo.at(-1)!.file_id);
return result;
}3. Таймзона и SQLite. Сервер работает в MSK (UTC+3), SQLite хранит даты как текст в UTC. При выборке «отправить в 10:00 по Москве» scheduled-checker сравнивал строки без учёта смещения — уведомления уходили на 3 часа раньше. Фикс: все даты хранятся и сравниваются в UTC, конвертация в MSK только для отображения в UI.
Другие находки
- Redirect-заголовки в HTTP API (
index.ts) не обрабатывали trailing slash — некоторые запросы из админки падали с 404 - Regex для валидации телефона не покрывал номера с пробелами и скобками (пользователи вводят
+7 (999) 123-45-67) scheduled-checker.tsне логировал ошибки отправки — при сбое Telegram API уведомление молча пропадало без retry
Аналитика: воронка прямо в HTML-админке
После стабилизации бота добавили четвёртую вкладку в public/admin-bot.html — «📈 Аналитика» с графической воронкой регистрации.
Админка — это чистый HTML без фреймворков, с инлайн-скриптами. Новая вкладка рисует воронку через CSS-градиенты и абсолютное позиционирование, без Canvas и без внешних библиотек.
Воронка показывает:
- Открыли бота (нажали Start)
- Ввели имя
- Ввели телефон
- Ввели email
- Завершили регистрацию
Для каждого шага — количество пользователей и процент конверсии относительно предыдущего шага. Данные тянутся из того же API на порту 3097, эндпоинт /api/analytics/funnel.
Один UX-баг поймали при тестировании: если на каком-то шаге 0 пользователей из предыдущего шага (например, никто ещё не дошёл до email), формула (current / previous * 100) давала NaN или Infinity, и в UI появлялась надпись «100% отвалились» при нулевых данных. Добавили guard:
const dropRate = prev > 0 ? Math.round((1 - curr / prev) * 100) : 0;Также при добавлении четвёртой кнопки вкладки на мобильных устройствах таб-бар уходил за пределы экрана. Пофиксили через flex-wrap: wrap и min-width: 0 на кнопках — теперь вкладки переносятся на вторую строку на узких экранах.
Результат
Бот отработал все три дня саммита без критических инцидентов. Что получили в итоге:
- Регистрация через Telegram — без редиректов, без внешних форм
- Автоматические напоминания за час до каждого дня трансляции
- Редактируемые тексты через веб-админку (после фикса seed)
- Аналитическая воронка с конверсией по каждому шагу
- Code review закрыл три критических риска до боевой рассылки
Выводы
Главный урок этого проекта — seed-функции должны быть идемпотентными. INSERT OR REPLACE в инициализации — это мина замедленного действия: всё работает на dev, но на prod каждый рестарт сервиса уничтожает пользовательские настройки. Всегда используйте INSERT OR IGNORE или проверяйте существование записи перед вставкой.
Второй урок — единый parse_mode на весь проект. Смешивать Markdown и HTML в одном боте — верный путь к трудновоспроизводимым багам. Telegram API не прощает несоответствия: сообщение либо отправляется, либо падает с 400. Выберите один формат в начале проекта и зафиксируйте его в константе или конфиге.
Третий урок — таймзоны убивают молча. Scheduled-уведомления, которые уходят на 3 часа раньше — это не очевидная ошибка в логах, это просто «странное поведение», которое замечают пользователи. Храните всё в UTC, конвертируйте только на границе отображения.
Четвёртый урок — code review перед рассылкой обязателен, даже если бот «работает». Тестовая отправка одному пользователю не воспроизводит проблемы с кэшированием file_id, с обрезкой caption, с retry при сбоях API. Нагрузочные сценарии нужно проверять явно, а не надеяться, что «в тесте всё было нормально».
Проект показал, что SQLite + Bun + Telegram Bot API — вполне жизнеспособный стек для небольших event-ботов. Никакой Redis, никакой RabbitMQ, никакого Kubernetes — просто systemd-сервис, файловая база и 556 строк TypeScript.
Ссылки

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.