П/ВИН

Бот для мероприятия: регистрация на Holistic4u Summit

·8 мин чтения

Собрать регистрацию на живое мероприятие за несколько дней - без форм, без редиректов и с мгновенными напоминаниями в мессенджер - задача, которая раньше казалась мне головной болью. Для проекта Holistic4u Summit мы решили её через бот для мероприятия прямо в Telegram - и это оказалось неожиданно элегантно. Саммит о биохакинге, холистическом здоровье и будущем индустрии велнеса проходил 29-31 июля в онлайн-формате и оффлайн в Москве на ВДНХ.

В этой статье расскажу, как устроен бот изнутри, какие грабли мы собрали на этапе code review перед боевой рассылкой, и как прикрутили аналитическую воронку прямо в HTML-админку.

Контекст: зачем бот, а не форма

Проект h4b-landing — это Next.js-лендинг для Holistic4u. Лендинг отдаёт статику, собирает лиды, но для регистрации нужен был живой диалог: имя, телефон, email, подтверждение формата участия (онлайн или оффлайн). Классическая форма на сайте требует либо стороннего сервиса рассылок, либо собственного бэкенда с очередями. Telegram-бот решает обе проблемы разом: пользователь регистрируется в привычном интерфейсе, а напоминания о трансляции приходят туда же, без email-спама.

Архитектурно бот живёт в поддиректории src-bot/ того же монорепо:

  • index.ts — точка входа, поднимает HTTP API для админки на порту 3097
  • bot.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 и без внешних библиотек.

Воронка показывает:

  1. Открыли бота (нажали Start)
  2. Ввели имя
  3. Ввели телефон
  4. Ввели email
  5. Завершили регистрацию

Для каждого шага — количество пользователей и процент конверсии относительно предыдущего шага. Данные тянутся из того же 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 в бизнес.