П/ВИН

Рассылка в Телеграм: своя CRM с анти-спамом для прогрева

·10 мин чтения

Несколько сотен подписчиков в личке - и каждый день я вручную открывал десятки диалогов, копировал приглашение на гостевой мастермайнд, подставлял имя человека и отправлял, одно за другим. Пока контактов была пара десятков, это был терпимый ритуал; когда их стало несколько сотен, копи-паст начал съедать часы, я путался, кому уже написал, а кому нет, а сам Telegram косился на однотипные сообщения и грозил спам-блоком. Так обычная рассылка в телеграм превратилась для меня в проблему, которую мы вместе с моим CRM-мессенджером собрали в управляемую систему - с когортами, кампаниями, анти-спам движком и умной подстановкой имён.

Что такое crm-messenger и зачем он появился

Перед тем как автоматизировать рассылки, у меня уже был самописный CRM-мессенджер поверх Telegram. Это связка из двух частей: бэкенд на Python (systemd-сервис с привязанной Telegram-сессией через MTProto) и фронтенд на Next.js, задеплоенный отдельно. Бэкенд держит живую сессию Telegram, тянет входящие и исходящие сообщения, а фронтенд рисует привычный интерфейс мессенджера — список диалогов, счётчики непрочитанного, история переписки.

Данные лежат в self-hosted Supabase (Postgres + PostgREST): таблицы контактов, сообщений, а также cohorts — когорты клиентов, которые синхронизируются из основной CRM. Идея когорт ключевая: это не просто список контактов, а сегменты — например, «пришли с вебинара», «оплатили диагностику», «холодные лиды». Именно по когортам мы потом и таргетируем рассылки.

Проблема №1: рассинхрон прочтений

Первое, с чего мы начали, — навести порядок. Ситуация была абсурдная: в CRM висело 1795 непрочитанных входящих в 264 диалогах, хотя в самом Telegram на телефоне непрочитанных было буквально 5-6. Мессенджер и Telegram разошлись в состоянии.

Корень проблемы — в том, что синхронизировалось не всё. В API уже были два механизма:

  • POST /api/messages/sync-history?days=N — догружает пропущенные сообщения из Telegram (фильтр по дате, до 200 сообщений на контакт).
  • POST /api/messages/{contact_id}/check-read-receipts — синхронизирует исходящие прочтения (read_outbox_max_idread_by_contact), то есть видит ли клиент моё сообщение.

А вот синхронизации входящих прочтений (read_inbox_max_id из Telegram → is_read в базе) не было вовсе. Именно поэтому у меня в CRM копились «непрочитанные», которые я давно прочитал с телефона.

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

Было: 1795 непрочитанных в 264 диалогах. Стало: 0 / 0 / 0.

Счётчики на бейджах сбросились, синие подсветки ушли после рефреша. Важная деталь: массовая простановка is_read не трогает сами сообщения, поэтому при желании можно было бы отдельно прогнать sync-history и добрать пропуски, не ломая read-state. Чистая база — фундамент, на котором можно строить автоматизацию.

Проблема №2: рассылки, которые не улетают в спам

Дальше — главное. Я сформулировал задачу так: нужно уметь выбрать когорты клиентов и разослать им приглашение, но при этом не попасть под спам-фильтры Telegram и обращаться к человеку по имени, если это уместно.

Мы сели в режим брейншторма и разложили требования по полочкам. Вот что получилось.

Иерархия: Кампания → Шаг → 4 варианта

Самое важное архитектурное решение — не хардкодить «одно приглашение», а заложить модель под будущее. Я объяснил, что сегодня это гостевой мастермайнд с первым сообщением, но завтра появится второе, третье сообщение (drip-цепочка), а послезавтра — совсем другой продукт вроде автовебинара со своей логикой.

Отсюда трёхуровневая модель данных:

campaigns (продукт)
  └── campaign_steps (этапы: шаг 1, 2, 3...)
        └── step_variants (4 варианта текста: A/B/C/D)

MVP — гостевой мастермайнд, шаг 1. Но схема сразу заложена под drip-цепочки и другие продукты. Так мы не переписываем всё через месяц.

Анти-спам: 4 варианта + round-robin + гомоглифы

Telegram не публикует свои лимиты и правила спам-детекции официально, но эмпирика Telethon-сообщества хорошо известна: одинаковые сообщения массово с одного аккаунта — прямой путь к блоку. Мы построили многослойную защиту.

Слой 1 — четыре варианта текста. Я пишу 4 разных формулировки приглашения, и система раздаёт их по кругу (round-robin): контакт 1 → вариант A, контакт 2 → B, контакт 3 → C, контакт 4 → D, контакт 5 → снова A. В превью показывается распределение (например, A — 67, B — 67, C — 66, D — 64).

Слой 2 — уникализация каждого сообщения через гомоглифы. Даже внутри одного варианта каждое отправленное сообщение делается уникальным на уровне байтов. Латинская c подменяется на визуально идентичную кириллическую с, добавляются неразрывные пробелы и zero-width символы. Для человека текст выглядит одинаково, а для Telegram у каждого сообщения разный хэш — дубли не палятся.

HOMOGLYPHS = {
    'a': 'а', 'c': 'с', 'e': 'е', 'o': 'о',  # latin -> cyrillic
    'p': 'р', 'x': 'х', 'y': 'у',
}
ZERO_WIDTH = '\u200b'  # zero-width space
 
def uniquify(text: str) -> str:
    # случайно подменяем часть символов на гомоглифы
    # и вставляем zero-width — hash меняется, вид остаётся
    ...

От замены «триггерных слов» (когда фразы вроде «бесплатное обучение» автоматически переписываются) в итоге отказались — я решил, что проще один раз придумать универсальные формулировки, которые не цепляют фильтры, чем городить словарь замен.

Слой 3 — настраиваемые лимиты в час и в день, плюс минимальный интервал между сообщениями (в исходном мессенджере уже был 3-секундный rate limit). Дефолты мы выставили по эмпирике Telethon-сообщества, чтобы не «шокировать» аккаунт всплеском отправок.

Умная подстановка имени

Я хотел персонализацию: «Привет, !». Но имена в Telegram — это боль. У кого-то там 🎯Мария❤️, у кого-то xXx_trader_2000, у кого-то иероглифы. Подставлять такое в приветствие — позор.

Решение — regex-валидация. Имя проходит, только если это чистые буквы (кириллица или латиница), пробел или дефис, длиной 2-30 символов, без цифр, эмодзи и спецсимволов. Если имя не прошло валидацию — плейсхолдер {имя} вырезается, а пунктуация вокруг подчищается, чтобы не осталось «Привет, !».

NAME_RE = re.compile(r'^[а-яёa-z\s-]{2,30}$', re.IGNORECASE)
 
def render(template: str, name: str | None) -> str:
    if name and NAME_RE.match(name.strip()):
        return template.replace('{имя}', name.strip())
    # имя не прошло — убираем плейсхолдер и лишнюю пунктуацию
    return cleanup_punctuation(template.replace('{имя}', ''))

«Привет, ! Приглашаю...» → для валидного имени: «Привет, Мария! Приглашаю...», для мусорного: «Привет! Приглашаю...». Просто и надёжно.

Превью перед стартом

Отдельно я настаивал на сверке перед отправкой — потому что рассылка на сотни человек необратима. Экран подтверждения показывает: сколько контактов в выбранных когортах (с разбивкой), сколько сообщений всего, распределение по 4 вариантам, за какое примерное время всё улетит с учётом лимитов. Плюс дедупликация контактов, исключения по истории и статусам, и тестовая отправка самому себе перед боевым запуском.

Неочевидный инсайт: удаление как инструмент прогрева

Самый интересный поворот случился с логикой удаления сообщений. Сначала я воспринимал «удалить» как откат ошибки. Но потом переформулировал — и это перевернуло дизайн.

Подумайте: клиент прочитал первое приглашение и не ответил. Если я просто напишу ему второе сообщение в тот же тред — это выглядит как спам-долбёжка. Но если я удалю старое сообщение (через delete_messages(revoke=True)) и отправлю заново — Telegram покажет это как новое поступление, клиент снова получит уведомление. Для тех, кто вообще не открыл первое сообщение, эффект ещё чище.

То есть удаление — это не «отмена», а основной инструмент ре-энгейджмента. Поэтому доступ к нему нужен не только админу, но и менеджерам — это их рабочий инструмент.

Есть нюанс: Telegram позволяет отозвать (revoke) чужое сообщение только в течение 48 часов. Первым импульсом было делать кнопку «Удалить в TG» неактивной для старых run'ов. Но я поправил: кнопка всегда активна, а при попытке удалить сообщение старше 48 часов показывается toast — «TG не позволяет revoke старше 48 часов, у клиента сообщение останется» — и предлагается «Всё равно скрыть у нас?» (пометить deleted_at локально). Пользователь должен понимать, что происходит, а не упираться в серую кнопку.

Двусторонняя синхронизация с CRM

Мессенджер не живёт в вакууме — он связан с основной CRM (Twenty). При каждой доставке сообщения на карточку клиента прилетает Note в таймлайн: «📨 Рассылка: [Гостевой мастермайнд] / [Шаг 1: Приглашение] / вариант A — 2 мая 14:32», а в теле — чистая версия текста (без гомоглифов, чтобы читать было приятно). Плюс на Person обновляются агрегаты: lastBroadcastAt, broadcastCount. Так вся команда видит, кого и когда трогали рассылкой, прямо в CRM.

Как строили: спека, планы, TDD-субагенты

Задача была явно нетривиальной — 3+ архитектурных развилки, необратимые операции. Поэтому вместо «сразу кодить» мы прошли полный цикл: брейншторм по секциям → спека → план → реализация.

Спека вышла солидной: иерархия кампаний, полная схема БД (3 новые таблицы + расширения существующих), 20+ эндпоинтов, анти-спам pipeline, worker с persisted-queue (переживает рестарт сервиса — критично, потому что бэкенд периодически рестартуется). Спека разбилась на 3 независимых плана, каждый шипится отдельно. План A (Foundation, отправка) — это 18 задач: миграция схемы, чистые утилитарные модули, sender worker, CRUD кампаний, эндпоинты preview/start/pause/resume/cancel и базовый UI.

Реализацию вели через subagent-driven development: свежий субагент на каждую задачу, двухстадийное ревью после каждой (сначала соответствие спеке, потом качество кода). Обычно скилл требует изоляции в git worktree, но тут был нюанс: бэкенд — это live systemd-сервис с единственной Telegram-сессией, две копии одновременно не запустить. Решили работать прямо в master, благо мессенджером в тот момент никто активно не пользовался. Утилитарные модули писали по TDD: name_validator — 22/22 тестов зелёные, uniquify — 8/8, следом render. Чистые функции без сайд-эффектов идеально ложатся на TDD — сначала тест, потом реализация.

Результат и выводы

Из ежедневного ручного копи-паста на часы получилась система, где я выбираю когорты, жму «превью», проверяю распределение и запускаю рассылку, которая сама раздаёт 4 варианта по кругу, уникализирует каждое сообщение, аккуратно подставляет имена и держит лимиты, чтобы не словить блок. А удаление старых сообщений стало полноценным инструментом повторного касания.

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

Второй урок — закладывай модель данных под будущее, а код пиши под настоящее. Мы не стали хардкодить одно приглашение, а сразу сделали иерархию Кампания → Шаг → Варианты. Но при этом реализовали только MVP (шаг 1 гостевого мастермайнда), не пытаясь построить drip-движок, который пока не нужен. YAGNI на уровне кода, дальновидность на уровне схемы БД — это разные вещи, и путать их дорого.

Третий урок — переформулировка задачи может изменить всю архитектуру. История с удалением сообщений — идеальный пример. Пока я считал удаление «откатом ошибки», UI получался защитным и куцым (серые неактивные кнопки). Как только я осознал, что удаление — это инструмент ре-энгейджмента, весь раздел переписался: кнопки для менеджеров, честные toast-предупреждения про 48 часов, workflow повторной отправки. Прежде чем кодить фичу, стоит спросить: а какую работу этот инструмент на самом деле делает для пользователя?

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

Впереди — планы B и C: drip-цепочки с автоматическим запуском шагов 2, 3, 4 по расписанию и другие продукты. Но фундамент уже стоит, и он спроектирован так, чтобы это надстроить, а не переписать.

Полезные ссылки

Паша Вин
Паша Вин

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