П/ВИН

Массовая рассылка в Телеграм: anti-spam в crm-messenger

·9 мин чтения

Однажды утром я открыл свой CRM-мессенджер и увидел 1795 непрочитанных входящих в 264 диалогах, а на телефоне в Telegram - пять-шесть. Классический рассинхрон: прочтения уже случились в мобильном клиенте, а серверная база об этом не знает. Из этого тривиального сценария у меня выросла большая задача - сделать массовую рассылку в телеграм поверх живого аккаунта так, чтобы она не падала в спам-фильтры, не отправляла дубликат тому, кто уже ответил, и позволяла менеджерам перезапускать кампании через удаление-и-новое-сообщение.

В этой статье я разбираю, как мы спроектировали broadcast-модуль для crm-messenger, какие компромиссы пришлось принять между гибкостью схемы данных и простотой UI, и почему «удалить сообщение» в нашем контексте — это не откат ошибки, а основной инструмент re-engagement.

Контекст: что такое crm-messenger

crm-messenger — это надстройка над Telegram-аккаунтом владельца, которая агрегирует переписки с подписчиками и стыкуется с Twenty CRM. Бэкенд написан на Python (FastAPI + Telethon), хранит сообщения и метаданные в самохостинговом Supabase. Фронт — Next.js, деплой через Dokploy.

Изначальная задача звучала так: «У меня есть скрипт общения с подписчиками в Telegram, надо его автоматизировать». До этого менеджеры писали каждое приглашение на гостевой мастер-майнд руками — копировали шаблон, подставляли имя, отправляли по 100–200 человек в день, периодически промахивались мимо когорт и дублировались с уже отвечавшими.

Стартовая проблема — рассинхрон прочтений

Перед тем как браться за рассылки, я провалидировал состояние БД. В CRM висели 1795 непрочитанных входящих, в реальности — горстка. У нас уже был эндпоинт POST /api/messages/{contact_id}/check-read-receipts для синхронизации исходящих, но не было обратного — read_inbox_max_id из Telegram в is_read нашей БД. В тот момент решили не строить полную двунаправленную синхронизацию (это отдельная история), а просто пометить всё прочитанным разовой миграцией. Результат — 0 / 0 / 0, чистый старт.

Этот эпизод хорошо иллюстрирует общий принцип: когда у тебя есть два источника истины (Telegram-сервер и собственная БД), нужно явно решать, кто из них primary для каждого факта. Для read-state primary — Telegram (там пользователь физически тапает по чату), для метаданных кампаний — наша БД (Telegram про них ничего не знает).

Архитектура рассылок: Кампания → Шаг → 4 варианта

Когда менеджер сказал «у нас 4 варианта сообщения, чтобы не попасть в спам-фильтры Telegram, и в начале нужно подставлять имя», я первым делом нарисовал иерархию. Получилось три уровня:

  • Кампания (Campaign) — продукт или акция: «Гостевой мастер-майнд», «АвтоВеб», «Чёрная пятница».
  • Шаг (Step) — этап внутри кампании: «Шаг 1: Приглашение», «Шаг 2: Напоминание тем, кто не ответил».
  • Вариант (Variant) — четыре текстовых слота A/B/C/D внутри шага.

Такая модель закладывает запас под drip-кампании (несколько шагов с задержкой), хотя MVP пилит только ручной запуск каждого шага. Это классический случай «схема данных — на вырост, UI — минимальный».

CREATE TABLE campaigns (
  id UUID PRIMARY KEY,
  name TEXT NOT NULL,
  description TEXT,
  delete_window_hours INTEGER DEFAULT 48
);
 
CREATE TABLE campaign_steps (
  id UUID PRIMARY KEY,
  campaign_id UUID REFERENCES campaigns(id),
  ord INTEGER,
  name TEXT,
  delay_days INTEGER
);
 
CREATE TABLE step_variants (
  id UUID PRIMARY KEY,
  step_id UUID REFERENCES campaign_steps(id),
  slot CHAR(1) CHECK (slot IN ('A', 'B', 'C', 'D')),
  text TEXT
);

Распределение по вариантам — round-robin по кругу. Первый получатель — A, второй — B, третий — C, четвёртый — D, пятый снова A. Это даёт ровно 25% по каждому варианту в пределе и быстро выравнивает доли при остановках/паузах.

Anti-spam pipeline: уникализация каждого сообщения

Тут начинается самое интересное. Telegram не публикует свои anti-spam правила, но эмпирически: если бот шлёт 200 байт-в-байт одинаковых сообщений за час — прилетает FloodWait или вообще бан аккаунта. Поэтому 4 варианта недостаточно — внутри одного варианта 50 сообщений всё равно будут идентичны.

Я добавил слой уникализации через гомоглифы — подмену визуально неотличимых символов. Латинская c и кириллическая с рендерятся одинаково, но имеют разные code points (U+0063 vs U+0441). Аналогично a/а, e/е, o/о, p/р, x/х, y/у, H/Н, K/К. Плюс zero-width space (U+200B) и неразрывные пробелы (U+00A0) в случайных позициях.

HOMOGLYPHS = {
    'c': 'с', 'a': 'а', 'e': 'е', 'o': 'о',
    'p': 'р', 'x': 'х', 'y': 'у',
    'H': 'Н', 'K': 'К', 'M': 'М',
}
 
def uniquify(text: str, seed: int) -> str:
    rng = random.Random(seed)
    chars = list(text)
    for i, ch in enumerate(chars):
        if ch in HOMOGLYPHS and rng.random() < 0.3:
            chars[i] = HOMOGLYPHS[ch]
    return ''.join(chars)

Каждое сообщение получает уникальный seed (из delivery.id) — значит, его hash гарантированно отличается от соседних. Visual diff для человека — нулевой. Подробности про unicode confusables — в Unicode TR39.

Валидация имени: regex плюс fallback на пустоту

Менеджер сказал: «Имя пишем только если понятное на русском или транслитом, иначе без имени». Простой whitelist:

  • Только буквы (кириллица + латиница), пробел, дефис.
  • Длина 2–30 символов.
  • Никаких цифр, эмодзи, спецсимволов (@, _, *, 🎯, ❤️).

Если first_name из Telegram — 🌸Алёна🌸 или xX_killer_Xx или пусто, шаблон "Привет, {имя}!" рендерится в "Привет!" с подчисткой пунктуации (двойных запятых, лишних пробелов).

NAME_REGEX = re.compile(r'^[а-яА-ЯёЁa-zA-Z\s\-]{2,30}$')
 
def validate_name(raw: str | None) -> str | None:
    if not raw:
        return None
    cleaned = raw.strip()
    return cleaned if NAME_REGEX.match(cleaned) else None
 
def render(template: str, name: str | None) -> str:
    if name:
        return template.replace('{имя}', name)
    text = template.replace(', {имя}', '').replace('{имя}', '')
    return re.sub(r'\s+', ' ', text).strip()

Удаление как инструмент re-engagement

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

  • Клиент прочитал, не ответил → удаляем старое сообщение через revoke=True → отправляем новое в новом варианте → у клиента это выглядит как новое уведомление, а не продолжение треда → шанс на ответ выше.
  • Клиент вообще не открывал → удаляем → переотправляем с другого ракурса → не ощущается как спам.

Здесь критичен 48-часовой лимит Telegram: после 48 часов revoke у получателя невозможен. Менеджер попросил не блокировать кнопку, а показывать toast «TG не позволяет revoke старше 48 часов, у клиента сообщение останется. Скрыть только у нас?» — и предлагать локальное удаление. Это правильный UX-выбор: не отнимать у пользователя возможность попробовать, но честно объяснять последствия.

Доступ к удалению — не только админский. Это основной инструмент менеджеров, поэтому права роли manager включают delete + refire.

Лимиты и rate limiting

Защита от FloodWait — двухуровневая:

  • Минимальный интервал между сообщениями: дефолт 3–7 секунд (рандомизированный).
  • Сообщений в час / в день: настраиваемые на уровне кампании, дефолты подобраны эмпирически (60/час, 300/день для свежего аккаунта; 120/час, 1000/день для прогретого).

Worker, который рассылает, использует persisted queue в Postgres — переживает рестарт сервиса без потери прогресса. После каждой отправки апдейтит delivery.status и delivery.sent_at атомарно, что позволяет точно знать, кому что ушло, даже если процесс упал посередине пачки. Подробности про FloodWait и обработку ошибок — в гайде Telethon.

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

Менеджер: «Нужна сверка — сколько сообщений, за какое время, кому в итоге отправляем». Экран /broadcast/runs/{id}/preview показывает:

  • Количество контактов в выбранных когортах (с разбивкой).
  • Распределение по 4 вариантам (A — 67, B — 67, C — 66, D — 66).
  • Дедуп: исключены те, кому уже слали этот шаг (по messages history).
  • Исключения по статусам Twenty CRM (например, «клиент» уже не получает приглашения).
  • ETA: при текущих лимитах — ~3 ч 40 мин.
  • Кнопка «Тестовая отправка себе» — отрендерить случайный вариант для своего аккаунта.

Только после превью — кнопка «Запустить».

Bidirectional sync с Twenty CRM

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

Результат

Реализация заняла три плана:

  • Plan A (Foundation): schema migration, pure-utility модули (name_validator, uniquify, render, distributor, rate_limiter), sender worker, CRUD кампаний/шагов/вариантов, preview/test/start/pause/resume/cancel, базовый UI. 18 задач, ~3160 строк спецификации.
  • Plan B (Re-engagement): меню удаления в чат-бабблах, refire с другим вариантом, run inspection с сегментами по статусам.
  • Plan C (Twenty CRM sync): Notes на Person/Opportunity, агрегаты, события timeline.

Метрики после первого реального run:

  • 1795 → 0 непрочитанных в стартовом cleanup.
  • 264 контакта в первой кампании, 4 варианта × ~66 человек.
  • 0 FloodWait при дефолтных лимитах.
  • ETA-предсказание совпало с фактом ±5 минут.
  • Менеджер запустил вторую волну (refire по неответившим) через 5 дней — конверсия в ответ выросла относительно первой волны.

Выводы

1. «Удалить» может означать совсем не то, что кажется. Я почти спроектировал deletion как откат ошибки. Менеджер развернул это в основной workflow. Урок: всегда проверяй, какой ментальной моделью пользователь оперирует — не предполагай, что твоя default-интерпретация правильная. Один-два уточняющих вопроса экономят дни работы.

2. Anti-spam через гомоглифы — недокументированный, но работающий приём. Telegram не публикует, что считает дубликатом, но эмпирически hash-based детектор ловит точные совпадения. Достаточно поменять 30% символов на визуально идентичные unicode-варианты, и hash меняется полностью. Минимальное изменение UX, максимальная защита аккаунта. Базовая спецификация Telegram доступна в официальной документации Bot API, хотя сам user-аккаунт мы гоняем через Telethon.

3. Превью обязателен для любой массовой операции. Когда речь о 200+ сообщениях, цена ошибки — бан аккаунта, репутация в чёрном списке TG, недели прогрева новой сессии. Тестовая отправка себе + статистика по когортам/вариантам — must-have, а не nice-to-have. Без preview-шага в проде через неделю обязательно случается «упс, я отправил черновик всем».

4. Schema на вырост, UI на сейчас. Иерархия Кампания→Шаг→Вариант поддерживает drip-кампании, но MVP запускает только ручной режим. Это даёт возможность через месяц добавить cron-планировщик без миграций. YAGNI работает в коде, но не в дизайне БД — там, наоборот, лучше предусмотреть избыточность, потому что миграция данных дороже, чем неиспользуемая колонка.

5. Persisted queue побеждает in-memory. Sender worker с состоянием в Postgres переживает systemctl restart без потери прогресса. Это критично для рассылок длительностью 3–4 часа — нельзя позволить себе перезапустить и не знать, кому уже ушло. Альтернативный подход с in-memory очередью FastAPI background tasks мы рассматривали — но он не выживает крэш процесса. Подробнее про сами background tasks — в документации FastAPI, а про работу с Supabase — в официальной документации.

6. Двунаправленная синхронизация — отдельный продукт. Я отложил полный sync inbox-прочтений, потому что он требует листенера в Telethon на UpdateReadHistoryInbox и аккуратной обработки race conditions. Вместо этого — разовый cleanup и ручной триггер. Иногда правильный путь — отложить сложность, если она не на критическом пути.

Если у тебя похожая задача — массовые рассылки в Telegram через user-аккаунт, не через Bot API — главный совет: считай каждое сообщение единицей риска для аккаунта. Не экономь на anti-spam, обязательно делай preview, давай менеджеру кнопки «пауза / возобновление / отмена» — и не блокируй опасные действия, а показывай их последствия. Это сместит ответственность туда, где она и должна быть.

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

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