Есть моменты в разработке, когда решение приходит не через усложнение, а через упрощение. И тогда понимаешь: вот оно, правильное.

Контекст: система, которая «почти» работала

У нас есть бот-архивариус для сообщества СуккоГрадъ. Он принимает вебхуки из MAX, сохраняет посты, комментарии, сообщения чатов в БД, публикует в Telegram и на YouTube. Классический «швейцарский нож» для сообщества.

Архитектура:

  • MAX — источник событий (посты, комментарии, чаты).
  • PHP-приёмник на сайте — ловит вебхуки, кладёт в queue.jsonl.
  • Python-воркеры — читают очередь, обрабатывают, публикуют.

Долгое время всё работало. Но была одна проблема — синхронизация. Это «догоняющий механизм» на случай, если бот был выключен и вебхук пропущен. Он ходит в MAX API, тянет пропущенные посты и сохраняет в БД.

Проблема: два пути — две логики

Мы недавно переделали обработку вебхуков — сделали её правильной:

  • attachments сохраняются сразу (raw JSON).
  • media_status='pending' — маркер «надо скачать».
  • markup, channel_title, теги — заполняются.
  • Медиа скачивает отдельный воркер, а не обработчик вебхука.
  • Футер добавляется через processor — отдельный сервис.

Но sync остался старым. Он работал по логике «до рефакторинга»:

  • Сохранял пост напрямую в БД — без media_status.
  • Скачивал видео сам — в том же процессе.
  • Пытался править пост через API — чтобы добавить футер.

И тут вылезла проблема. Пост, который прошёл через sync, отличался от поста, который пришёл через вебхук:

  • channel_title = NULL.
  • markup = NULL.
  • media_status = NULL.
  • attachments — в другом формате.

Одна и та же сущность, но два разных пути. Классический архитектурный разрыв.

Тупик: попытка «починить» через API

Логичным казалось: «Давайте sync будет работать так же, как вебхук — но через API». То есть:

  1. sync находит пропущенный пост.
  2. Правит его через API — чтобы MAX прислал message_edited.
  3. handle_edit подхватывает — и обновляет БД.

Проверили на живом посте. Бот может редактировать чужие посты. MAX присылает message_edited. Всё сработало.

Но при этом сломали видео.

Смотрим вебхук, который пришёл после правки:

"attachments": [
  {
    "type": "share",   ← было "video"
    "payload": {"url": "http://vk.ru/..."}
  }
]

MAX пересобрал attachments. Увидел в тексте ссылку vk.ru/holidayhousesukko — заменил видео на превью ссылки. Видео исчезло.

Это был провал. Мы пытались «починить» систему, а сломали реальный пост.

Момент прозрения: «как футер»

Тогда вы сказали:

«Осталось реализовать синхронизацию через вебхук. Так, как это реализовано в добавлении футера».

И это была точка поворота.

Смотрим, как работает футер — и почему это правильно:

  1. handle — сохраняет пост в БД (raw attachments, media_status='pending').
  2. processor — берёт посты с processed=0.
  3. Правит пост через API — добавляет футер.
  4. MAX присылает message_edited.
  5. handle_edit — обновляет text и markup в БД.
  6. processed=1 — только после успеха.

Ключевое: processor не «синхронизирует» пост. Он правит уже существующий пост. А вебхук message_edited завершает цикл — обновляет БД.

Механизм элегантный:

  • Правка — через API.
  • Синхронизация БД — через вебхук.
  • Никакого дублирования логики.

Вопрос: а что если sync должен работать так же — не сам сохранять пост, а «притвориться вебхуком»?

Решение: sync пишет в queue.jsonl

Идея настолько простая, что её легко упустить:

Пусть sync пишет не в БД, а в ту же очередь, что и PHP-приёмник.

Схема:

sync → нашёл пропущенный пост в MAX
    → сформировал JSON в формате вебхука
    → записал в queue.jsonl
       ↓
queue_reader (в sukko-ingest)
    → читает queue.jsonl
    → передаёт в dispatcher.process_webhook_event
       ↓
dispatcher → MaxChannelHandler.handle
    → сохраняет пост с raw attachments, media_status='pending', markup, channel_title
       ↓
media_downloader → скачивает видео → media_status='ready'
processor → добавляет футер → MAX шлёт message_edited → handle_edit обновляет БД → processed=1
       ↓
publisher_tg → публикует в TG
publisher_yt → заливает на YouTube

Что изменилось:

  • sync больше не сохраняет в БД.
  • sync больше не скачивает медиа.
  • sync больше не правит посты через API.
  • sync просто пишет в очередь.

Всё остальное делают штатные сервисы. Один путь — одна логика.

Почему это просто

1. Одна точка входа. PHP пишет в queue.jsonl. И sync тоже. queue_reader не различает. Одинаковая обработка.

2. Нет дублирования. handle — единственное место, где сохраняется пост. processor — единственное, где добавляется футер. media-downloader — единственное, где скачивается видео.

3. Идемпотентность. Если пост уже в БД — handle не создаст дубль. get_existing_post_ids + ON DUPLICATE KEY UPDATE.

4. Прозрачность. sync стал маленьким — ~50 строк вместо ~500. Легко читать, легко тестировать.

5. Надёжность. queue.jsonl — единая точка отказа. Если что-то упадёт — видно где. Никаких «двух путей».

Почему казалось, что нужен API

Соблазн был велик: «раз MAX умеет редактировать — давайте через API». Но:

  • edit_message меняет attachments — MAX пересобирает их.
  • Риск потери медиа — высокий.
  • Логика раздваивается — вебхук отдельно, API отдельно.

API — мощный инструмент. Но он не должен быть «обходным путём» для синхронизации. Вебхук — вот правильный путь.

Мораль

«Гениальное» решение через API — казалось умным:

  • Правка поста → событие → синхронизация.

«Простое» решение через очередь — казалось тривиальным:

  • Пишем в очередь → всё работает.

Но именно простое победило:

  • Меньше кода.
  • Меньше путей.
  • Меньше ошибок.
  • Единая логика.

Эпилог

Когда-то кто-то сказал: «Совершенство достигнуто не тогда, когда нечего добавить, а когда нечего убрать».

Мы убрали из sync:

  • Сохранение в БД.
  • Скачивание медиа.
  • Правку через API.
  • Работу с тегами.

Осталось:

  • Найти пропущенные посты.
  • Записать их в очередь.

И всё заработало так же, как для вебхука. Потому что это и есть вебхук — просто созданный нами, а не MAX-ом.


P.S. А пост 733, который мы сломали, вы удалили. Это тоже урок: не все эксперименты удаётся откатить. Иногда — приходится признать потерю и двигаться дальше. Но ценность урока — выше потери одного поста.