Неделя 134. Рождение рассылок: восемь тысяч строк в 02:35 и эндпоинт, которого не было

Неделя 134. Рождение рассылок: восемь тысяч строк в 02:35 и эндпоинт, которого не было

Разбор этой статьи

AI-подкаст BotsellerРождение рассылок: восемь тысяч строк в 02:35 и эндпоинт, которого не было
0:00 / 0:00

Эту тему разобрали в подкасте. Слушай параллельно с чтением.

У этой серии есть привилегия, которой нет у обычного бортжурнала: я пишу о прошлом, зная будущее. Каждую строчку кода недели 134 я вижу не так, как видел тогда, в конце февраля 2026-го, а с ответом на руках. Вот этот ночной коммит на восемь тысяч строк? Я знаю, что он породил две недели фиксов. Вот эта скромная заготовка партнёрского кабинета? Я знаю, что через семь дней она развернётся в систему на восемь таблиц. Это как пересматривать фильм, когда уже знаешь, кто убийца.

Я Дмитрий Дьяконов, основатель Botseller AI. Это шестой выпуск серии «Ретроспектива», бортжурнал в прошлое, от настоящего к первому коммиту. В выпуске о неделе 135 я рассказывал детективную историю о рассылках, которые дублировались из-за регистра букв в username. Сегодня отматываю ещё на неделю назад, туда, где эти рассылки родились. Неделя 134, с 23 февраля по 1 марта: 250 коммитов в одиннадцати проектах, три ночных марафона и один баг, из-за которого клиент получал одно и то же сообщение от всех ботов сразу.

Ночь на 25 февраля: коммит на восемь тысяч строк

Началось всё не с рассылок. Началось с шаблонов.

Фундамент рассылок: 71 файл, шаблоны с переменными CRM и AI-генерация вариантов для спинтакса

Идея простая: если клиент отправляет сотни однотипных сообщений, ему нужны заготовки. Шаблон с переменными, которые подставляются из карточки лида: имя, дата записи, название услуги. И спинтакс: конструкция вида {Здравствуйте|Добрый день|Приветствую}, из которой каждому получателю случайно выбирается свой вариант. Одинаковые сообщения, отправленные подряд, мессенджеры не любят. Разные формулировки одного смысла снижают риск блокировки.

В 02:35 ночи 25 февраля в личный кабинет уехал коммит на 71 файл и почти восемь тысяч строк: шаблоны сообщений с полным циклом (создание, редактирование, дублирование, архив), маппинг переменных на поля CRM, превью сообщения в макете телефона, автопроверка лимитов мессенджеров и генерация синонимов для спинтакса нейросетью. Пишешь «Здравствуйте», нажимаешь кнопку, получаешь пять вариантов приветствия. В ту же ночь на бэкенде появилась таблица шаблонов и полный CRUD к ней.

Я помню это состояние: ты собрал огромный кусок функциональности, всё работает на локальной машине, и кажется, что самое сложное позади. Сейчас, зная продолжение, я читаю этот коммит иначе. Это был не финиш. Это был старт двухнедельного марафона, который закончится только в середине марта.

Первые сутки в бою: баги, которые мы заложили сами

Уже через сутки после релиза пошли первые звоночки, и почти все они были нашими собственными руками заложенными минами.

Первые сутки в бою: start_hour копировал stop_hour, WhatsApp с идентификатором Telegram и номера на восьмёрку

Рассылки создавались с пустым окном отправки. Причина оказалась унизительно простой: при сохранении правила рассылки поле start_hour копировало значение stop_hour. Окно отправки «с 21 до 21». Ноль часов. Рассылка честно ждала момента, который никогда не наступит, и не отправляла ничего.

Дальше выяснилось, что рассылка в WhatsApp в трёх местах кода использовала идентификатор Telegram. Копипаста при переносе логики с одного мессенджера на другой. Потом всплыла путаница полей: choice_bot.id вместо choice_bot.bot_id, два разных идентификатора с похожими именами. Потом нормализация телефонов: номера, начинающиеся с восьмёрки, не находились в мессенджере, пока мы не начали приводить их к формату с семёркой.

Хроника той ночи с 24 на 25 февраля в git выглядит как кардиограмма: коммиты в 02:33, 03:32, 03:40, 03:48, 04:13, 04:31. Два из них чинили только файл зависимостей, который сломался при добавлении новой библиотеки. В четыре утра это воспринимается как личное оскорбление.

Оглядываясь назад, я не могу сказать, что мы делали что-то неправильно. Каждый баг по отдельности занимал пять строк. Проблема была в другом: мы выкатили большой кусок системы разом и получили все пять строк одновременно, ночью, на живых клиентах.

Эндпоинт, которого не было

Самая показательная история недели даже не баг. Это дыра.

Цепочка доставки из 12 шагов и 404 вместо эндпоинта статусов: сообщения навечно висели «В очереди»

Когда рассылка отправляет сообщение, оно проходит длинную цепочку: CRM создаёт запись, ядро забирает её через очередь, шлюз мессенджера отправляет, провайдер доставляет и возвращает статус. Двенадцать шагов, четыре сервиса, две очереди. И на последнем шаге, там, где статус доставки должен вернуться обратно в CRM, обнаружилось, что эндпоинта обновления статусов не существует. Вообще. Шлюз честно отправлял запрос и получал 404.

Результат: все отправленные сообщения рассылки навечно висели в статусе «В очереди». Клиент смотрит на экран и видит, что ничего не отправлено. А сообщения на самом деле доставлены и даже прочитаны. Мы написали эндпоинт, добавили маппинг статусов провайдера на наши («отправлено», «доставлено», «прочитано», «ошибка») и связку через Redis, чтобы по идентификатору сообщения у провайдера найти сообщение в CRM.

Но настоящий подарок ждал глубже. Пока мы отлаживали цепочку, выяснилось, что все логи из обработчиков очередей молча исчезают. Форматтер логгера требовал идентификатор трассировки, который существует только в HTTP-запросах. В обработчике очереди его нет, форматтер падал, лог не писался, ошибка проглатывалась. Система не просто болела. Она болела молча, с зашитым ртом.

Слепая зона: обработчики очередей без trace ID, форматтер логгера падает и ошибки проглатываются

Это тот момент, который я сегодня считаю поворотным, хотя тогда он выглядел как строчка в списке техдолга. Именно на неделе 134 мы впервые упёрлись в то, что не видим собственную систему. Весь наш нынешний контур наблюдаемости, трассировки, алерты, дашборды, вырос из этого неприятного открытия: чинить распределённую систему вслепую нельзя.

Баг недели: сообщение от всех ботов сразу

К концу недели рассылки уже работали, и мы начали делать их умнее. Равномерное распределение нагрузки: если у клиента три бота, сообщения должны делиться между ними поровну, а не лететь через одного. Сортируем ботов по счётчику отправленных сообщений, выбираем наименее загруженного. Логично.

Баг недели: балансировщик видел нулевые счётчики, и клиент получал рассылку от всех ботов сразу

В три часа ночи 1 марта нашлась корневая причина того, почему распределение не работало: счётчик отправленных сообщений увеличивался только при подтверждении от мессенджера. Пока подтверждение шло, все новые сообщения видели у всех ботов ноль и дружно выбирали первого в списке. Алгоритм был правильный. Данные, на которые он смотрел, опаздывали.

А вечером того же дня всплыл баг, который я до сих пор вспоминаю с холодком. Логика рассылки публиковала сообщение в очередь, но не обновляла его статус в CRM. Следующий цикл обработки просыпался, видел «неотправленные» сообщения и отправлял их снова. Через другого бота. Потом ещё раз. Клиент получал одну и ту же рассылку от каждого бота аккаунта.

Лечение вышло архитектурным: мы ввели новый статус в жизненный цикл сообщения. Было «создано, доставлено, ошибка». Стало «создано, поставлено в очередь, доставлено, ошибка». Промежуточный статус «поставлено в очередь» помечает сообщения, которые уже забрал обработчик, чтобы второй обработчик не взял их повторно. Одна строчка в перечислении статусов, а по сути признание: у сообщения в распределённой системе жизнь сложнее, чем «есть или нет».

Читатель выпуска про неделю 135 уже знает, что и это был не конец. Через несколько дней дубли вернутся с другой стороны, через регистр букв в username. Сага о рассылках, как и положено саге, растянулась на несколько серий. Сегодня, спустя четыре месяца, рассылки стали одной из самых используемых функций платформы, и о том, как устроены рассылки в мессенджерах, мы написали отдельный разбор. Но фундамент со всеми его трещинами заливался именно в ту неделю.

Рига, зашитая в SQL

Отдельная история недели: часовые пояса. В настройках CRM поле «часовой пояс» существовало давно. Красивое поле, сохранялось в базу. Не использовалось нигде.

Хардкод Europe/Riga в SQL: семь мест в десяти файлах, единый модуль таймзон и классический хотфикс

Рассылки сравнивали рабочие часы клиента с временем по UTC. Клиент из Новосибирска настраивает окно «с 10 до 19», а система понимает это как «с 10 до 19 по Гринвичу» и молчит полдня. А в двух SQL-функциях подсчёта будущих записей и вовсе обнаружился зашитый в код часовой пояс Europe/Riga. Почему Рига? Потому что когда-то это было временное решение для одного клиента. Временные решения, как известно, самые долговечные.

За день мы вычистили семь мест в десяти файлах, где время сравнивалось неправильно, и собрали единый модуль работы с часовыми поясами. Через семь минут после мержа прилетел hotfix: классическая ошибка сравнения времени с таймзоной и без неё. Кто работал с datetime в Python, тот в цирке не смеётся.

С тех пор часовой пояс стал обычной настройкой заказчика, и каждый новый кусок платформы, от рассылок до отчётов, начинается с вопроса «а в чьём времени мы считаем?». Дорогой вопрос: тогда он стоил нам дня работы, зато теперь задаётся на этапе проектирования, а не в продакшене.

Каналы без нашего участия: MAX по QR-коду и девять интеграций за воскресенье

Параллельно с рассылками шла другая работа, которую я сегодня считаю стратегически более важной, хотя тогда она казалась рутиной. Самообслуживание каналов.

Самообслуживание каналов: QR-цикл авторизации MAX и девять интеграций агрегатора за выходные

К февралю подключение нового мессенджера к платформе выглядело так: клиент пишет в поддержку, мы руками создаём конфигурацию, обмениваемся токенами, проверяем. Каждый новый канал означал переписку и полчаса чьего-то времени. Пока клиентов десятки, это терпимо. Мы уже чувствовали, что скоро станет нетерпимо.

На неделе 134 Павел научил платформу создавать инстансы мессенджера MAX программно: запрос к партнёрскому API провайдера, получение QR-кода, привязка аккаунта сканированием. Забавная деталь: первые эндпоинты MAX по ошибке попали в роутер WhatsApp, фикс прилетел через 35 минут после мержа. А на фронтенде выросла полноценная машина состояний для QR-авторизации: ожидание, показ кода, успех, истечение, ошибка, с опросом каждую секунду и пятиминутным таймаутом.

А в воскресенье вечером, в 23:22, финальным аккордом недели уехал единый механизм подключения девяти каналов через агрегатор: Instagram, VK, почта, чат на сайте, Viber, Avito и другие. Три шага в интерфейсе, без единого письма в поддержку.

Вот здесь ретроспективная оптика особенно приятна. Сегодня самостоятельное подключение каналов воспринимается как норма, никто в команде не вспомнит, что когда-то было иначе. Ни один клиент не пишет нам «подключите мне Avito». И я знаю точную дату, когда эта норма родилась: 1 марта 2026 года, поздним воскресным вечером.

Журнал активности: свидетель, который всегда трезв

Ещё одна фича, родившаяся на неделе 134 и с тех пор ни разу не выключавшаяся: журнал активности лида.

Идея выросла из споров. Клиент говорит: «Статус лида кто-то поменял, это не я». Менеджер говорит: «Это не я». Бот молчит, но подозрение падает и на него. Разруливать такие ситуации без истории действий невозможно, слово против слова.

Журнал активности лида: 17 типов событий в режиме fire-and-forget, аудит не имеет права ломать бизнес-логику

Мы завели семнадцать типов событий: создание лида, переименование, смена статуса, сообщения, списание занятий с абонемента, запись на занятие и так далее. Каждое действие пишется в журнал в режиме fire-and-forget: если запись в журнал упадёт, основная операция не пострадает. Аудит не имеет права ломать бизнес-логику, которую он документирует.

На фронтенде появилась вкладка «Активность» в карточке лида: лента событий с иконками, сгруппированная по датам. Сегодня это одна из тех фич, про которые не пишут в маркетинговых материалах, но попробуй её отключить: первый же спорный случай вернёт её обратно.

Партнёрский кабинет: заготовка, которая знала своё будущее

В том самом ночном коммите на восемь тысяч строк, среди шаблонов и рассылок, пряталась пара файлов, никак не связанных с остальным: страница партнёрского кабинета на 622 строки и файл типов на 354. Просто заготовки. Вёрстка без логики.

Тогда это выглядело как «набросал, пока думал».

Заготовка партнёрского кабинета на 622 строки, которая через неделю развернулась в систему на восемь таблиц Сегодня я знаю: через неделю эти заготовки развернутся в полноценную партнёрскую программу с восемью таблицами, комиссиями, рангами и отслеживанием конверсий, о которой я подробно писал в ретроспективе недели 135. Идеи в продукте почти никогда не появляются внезапно. Сначала возникает файл-заготовка, царапина на будущее, а потом она обрастает системой. Git это помнит, даже когда я сам уже не помню.

Сайт: оптимизация, которая ухудшила всё на 116 процентов

Параллельно с платформой шла работа над сайтом, и там случился эпизод, который я рассказываю как анекдот, хотя ночью 1 марта было не смешно.

Ирония оптимизации: динамическая подгрузка ухудшила TBT на 116 процентов, а скучные robots.txt и llms.txt стали фундаментом

Замеры производительности главной страницы показали суровое: общий балл 45 из 100, главный контент грузится 7,8 секунды. Для сайта, который продаёт технологичный продукт, это неприлично. Сел оптимизировать. Первым решением стало отложить загрузку тяжёлых секций ниже первого экрана: классический приём, динамическая подгрузка компонентов.

Выкатил, замерил. Время блокировки главного потока выросло с 730 миллисекунд до 1570. Плюс 116 процентов. Оптимизация ухудшила именно ту метрику, которую должна была улучшить. Через девять минут после замера я откатил изменение. Победило решение проще и скучнее: одно CSS-свойство, которое говорит браузеру не рендерить контент за пределами экрана, пока до него не доскроллили.

В ту же ночь на сайте появились два маленьких файла, о которых тогда никто не думал всерьёз: robots.txt с правилами для AI-краулеров и llms.txt, файл-путеводитель по сайту для языковых моделей. Плюс подключение протокола IndexNow, чтобы поисковики узнавали о новых страницах за минуты, а не дни. Спустя четыре месяца могу сказать: из этих «маленьких файлов» выросла целая архитектура видимости сайта для AI-поиска, IndexNow стал рутинной частью каждого релиза, а урок «сначала замерь, потом хвали себя» я вспоминаю каждый раз, когда рука тянется к модной оптимизации.

За пределами платформы: мониторинг команды и ложная оплата в два часа ночи

Неделя 134 зацепила и внутренние инструменты. В системе мониторинга команды, о редизайне которой я рассказывал в выпуске 135, именно тогда появился «аккаунт-менеджер»: наблюдатель за 640 рабочими чатами с алертами быстрее пятнадцати секунд, если в клиентском чате появляется вопрос без ответа. И первые модели миссий и персон, из которых через неделю вырастет вся агентная подсистема. Читая эти коммиты сейчас, я вижу, как одна система «сначала просто смотрела», а потом начала действовать.

Ложный успех в 01:28 ночи: отказ платёжной системы прошёл маршрутизацию как успешный платёж

А в клубном pet-проекте случился инцидент, за который до сих пор стыдно. Пользовательница отменила подписку, и после отмены ей дважды пришло «Оплата прошла успешно!». Платёжная система прислала уведомление о неуспешном автосписании, а наш обработчик из-за неудачного условия маршрутизации счёл его успешным платежом. Деньги не списались, но представьте ощущения человека: ты отменил подписку, а тебе радостно сообщают об оплате. Фикс приехал в 01:28 ночи, с двойной проверкой статуса на каждом уровне. Урок из этой истории я потом перенёс и в основной продукт: уведомление об успехе должно проверять успех, а не предполагать его.

Урок недели: фича стоит столько, сколько стоит её хвост

Если наложить недели 134 и 135 друг на друга, виден паттерн, который я тогда не осознавал, а сейчас считаю главным выводом этой пары выпусков.

Синтез недели: фича стоит столько, сколько стоит её хвост стабилизации

Мега-коммит на восемь тысяч строк в 02:35 ощущался как завершение работы. По факту он был серединой. После релиза начался хвост: ночь фиксов 25 февраля, дыра со статусами, дубли от всех ботов 1 марта, регистр username на следующей неделе. Хвост стабилизации оказался длиннее и дороже самой фичи.

С тех пор я оцениваю фичи иначе. Не «сколько дней до релиза», а «сколько дней до последнего ночного коммита по этой теме». Между этими датами может лежать двухнедельная пропасть, и она не признак плохой команды. Она признак того, что фича встретилась с реальностью. Планировать нужно обе части: время на постройку и время на хвост. Если хвост не запланирован, он всё равно случится, просто за счёт сна.

И второе, тише, но важнее: стройте видимость до фичи, а не после. Все самые дорогие часы той недели мы потеряли не на написание кода, а на попытки понять, что происходит, в системе, которая молчала. Эндпоинт статусов, журнал активности, починка логов: всё это инструменты зрения. Код без зрения похож на автомобиль без лобового стекла: ехать можно, но недолго.

Что было дальше

Дальше была неделя 135: дубли из-за регистра букв, партнёрская программа v2, система прав для сотрудников и задачи в CRM. Рассылки, родившиеся на неделе 134, окончательно повзрослели именно там. А если отмотать вперёд до сегодняшнего дня, от той недели в продукте остались все шесть систем: шаблоны со спинтаксом, статусная модель сообщений, журнал активности, QR-подключение MAX, самообслуживание каналов и настройка часовых поясов. Неплохой выход для семи дней, половина из которых прошла в режиме тушения пожаров.

Все выпуски серии собраны в категории ретроспектива, а текущая хроника разработки продолжается в бортовом журнале.

FAQ

Что такое ретроспектива Botseller?

Серия «Ретроспектива», бортжурнал в прошлое: от настоящего к первому коммиту. Каждый выпуск разбирает одну неделю разработки с git-логами, решениями, ошибками и личными уроками основателя. Особенность серии в обратной оптике: мы описываем события, уже зная, к чему они привели. Все выпуски собраны в категории ретроспектива.

Что такое спинтакс и зачем он в рассылках?

Так называют синтаксис вида {Здравствуйте|Добрый день|Приветствую}: из вариантов в фигурных скобках каждому получателю случайно выбирается один. Сотни одинаковых сообщений подряд повышают риск блокировки отправителя в мессенджере, а вариативные формулировки того же смысла снижают его. В Botseller варианты для спинтакса можно генерировать автоматически: пишете фразу, нейросеть предлагает синонимы.

Почему сообщения массовой рассылки могут дублироваться?

Самая частая причина: незавершённый жизненный цикл сообщения. Если система публикует сообщение в очередь отправки, но не помечает его как «взятое в работу», следующий цикл обработки видит его как неотправленное и отправляет повторно, иногда через другого бота. Лечится промежуточным статусом «поставлено в очередь» и дедупликацией получателей до старта рассылки.

Как подключить мессенджер MAX к Botseller?

Через QR-код, как WhatsApp: платформа создаёт инстанс, показывает QR, вы сканируете его приложением MAX, и канал привязывается к аккаунту. Весь процесс занимает пару минут и не требует обращения в поддержку.

Что такое журнал активности лида?

Лента всех событий по лиду: кто и когда создал его, сменил статус, написал сообщение, списал занятие с абонемента. В Botseller журнал фиксирует семнадцать типов событий и доступен во вкладке «Активность» карточки лида. Главная задача журнала: снять споры «кто это сделал». У каждого действия есть автор и время.

Зачем CRM настройка часового пояса?

Всё, что связано со временем, зависит от неё: окна отправки рассылок, рабочие часы бота, расписание занятий, напоминания. Если система сравнивает местное время клиента со временем по UTC, рассылка с окном «с 10 до 19» может молчать полдня. В Botseller часовой пояс задаётся в настройках заказчика и применяется во всех модулях платформы.

Что такое llms.txt?

Текстовый файл в корне сайта, который помогает языковым моделям и AI-поисковикам ориентироваться в его содержимом: краткое описание проекта и аннотированные ссылки на ключевые страницы. Аналог карты сайта, но для нейросетей. Мы добавили llms.txt на сайт одними из первых в нише и позже развили его в полноценную структуру для AI-поиска.