П/ВИН

Anti-ban улучшения для Telegram-армии: как мы защищаем аккаунты

·8 мин чтения
Anti-ban улучшения для Telegram-армии: как мы защищаем аккаунты

Если ты когда-либо управлял пулом Telegram-аккаунтов для автоматизированных задач, ты знаешь это чувство: заходишь утром, а половина аккаунтов уже залочена. Telegram становится умнее с каждым месяцем, и то, что работало год назад, сегодня убивает аккаунты за считанные часы. Именно с этой проблемой мы столкнулись в проекте tg-army — и именно её мы решали в рамках плана 2026-03-13-anti-ban-improvements.

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

Контекст: что такое tg-army и почему банят аккаунты

tg-army — это оркестратор для управления пулом Telegram-аккаунтов. Аккаунты закупаются на маркетплейсе, проходят автоматический setup (установка аватара, bio, 2FA), прогрев и затем используются для отправки сообщений в каналы и группы.

Telegram банит аккаунты не случайно — он смотрит на паттерны поведения. Несколько триггеров, которые нас убивали:

  • Несоответствие гео: аккаунт зарегистрирован через российский номер, а работает через американский прокси. Антифрод Telegram это видит.
  • Слишком быстрый старт: аккаунт только что «приехал» на новый IP и сразу начинает активность. Реальный пользователь так не делает.
  • Роботоподобная отправка: сообщения улетают мгновенно, без паузы на «набор текста». Мгновенная отправка — явный признак бота.
  • Перегрузка каналами: один аккаунт состоит в 50+ каналах и активно постит во все. Это нереалистично для живого человека.

Мы знали об этих проблемах давно, но решали их по одной, реактивно. Пришло время системного подхода.

Что было запланировано и как мы это реализовали

План состоял из четырёх независимых задач + интеграционная проверка. Все задачи разрабатывались по TDD: сначала тест (красный), потом реализация (зелёный).

Задача 1: Geo-Match Validation

Проблема: мы закупали аккаунты с российскими номерами (+7), но назначали им прокси без учёта географии. Аккаунт с номером +7 916 XXX-XX-XX выходил через немецкий datacenter IP — и Telegram видел несоответствие.

Решение — функция validate_geo_match() в account_pool.py, которая сопоставляет телефонный код аккаунта с геолокацией прокси:

def validate_geo_match(phone: str, proxy_country: str) -> tuple[bool, str]:
    """Проверяет соответствие страны номера и прокси."""
    phone_country = get_country_by_phone(phone)
    
    if phone_country is None:
        return True, "unknown phone country, skipping check"
    
    if phone_country == proxy_country:
        return True, "geo match ok"
    
    # Разрешённые комбинации (например, RU номер + BY прокси)
    ALLOWED_MISMATCHES = {
        ("RU", "BY"), ("RU", "KZ"), ("RU", "UA"),
    }
    if (phone_country, proxy_country) in ALLOWED_MISMATCHES:
        return True, f"allowed mismatch: {phone_country} -> {proxy_country}"
    
    return False, f"geo mismatch: phone={phone_country}, proxy={proxy_country}"

Эта проверка интегрирована в account_setup.py в метод check_all(): если гео не совпадает — аккаунт пропускается с предупреждением, а в lolz_market.py добавлено предупреждение при закупке аккаунтов с несовместимыми прокси.

Задача 2: IP Settle Delay

Даже если гео совпадает — нельзя начинать активность сразу. Реальный пользователь, который переехал на новый IP (например, сменил провайдера или уехал в другой город), какое-то время просто существует — открывает чаты, читает, не делает ничего подозрительного.

Мы ввели задержку 12 часов с момента первого появления аккаунта на IP до начала активного setup:

def should_start_setup(account: Account, now: datetime = None) -> tuple[bool, str]:
    """Проверяет, прошло ли достаточно времени после смены IP."""
    if now is None:
        now = datetime.utcnow()
    
    if account.ip_assigned_at is None:
        return False, "ip_assigned_at not set"
    
    settle_hours = settings.get("IP_SETTLE_HOURS", 12)
    elapsed = now - account.ip_assigned_at
    
    if elapsed.total_seconds() < settle_hours * 3600:
        remaining = timedelta(hours=settle_hours) - elapsed
        return False, f"IP not settled yet, wait {remaining}"
    
    return True, "IP settled, ok to proceed"

Проверка should_start_setup встраивается в check_all() сразу после geo-match: если гео ок, но IP не «отлежался» — ждём. Значение 12 часов вынесено в настройки БД и легко меняется без деплоя.

Задача 3: Typing Simulation

Это одно из самых эффективных улучшений с точки зрения «человекоподобности». Telegram предоставляет метод sendChatAction для отправки статуса «печатает...». Мы начали его использовать перед каждой отправкой сообщения.

Но просто вызвать typing и сразу отправить — слишком предсказуемо. Поэтому добавили реалистичный расчёт задержки:

def calc_typing_delay(text: str, wpm: int = None) -> float:
    """
    Рассчитывает реалистичное время набора текста.
    Средний человек печатает 40-60 WPM.
    """
    if wpm is None:
        wpm = random.randint(35, 65)
    
    words = len(text.split())
    base_delay = (words / wpm) * 60  # секунды
    
    # Добавляем случайный джиттер ±20%
    jitter = base_delay * random.uniform(-0.2, 0.2)
    delay = base_delay + jitter
    
    # Ограничиваем: минимум 1с, максимум 15с
    return max(1.0, min(15.0, delay))
 
 
async def _simulate_typing(self, chat_id: int, text: str) -> None:
    """Имитирует набор текста перед отправкой."""
    delay = calc_typing_delay(text)
    await self.client.send_chat_action(chat_id, "typing")
    await asyncio.sleep(delay)

Эта логика добавлена в sender.py и вызывается перед send_text, send_photo, send_voice и отправкой альбома. Для медиа используем upload_photo/record_video action вместо typing.

Задача 4: Channel-Per-Account Limit

Последняя задача — ограничение количества каналов на один аккаунт. По нашим наблюдениям и данным из блога GramGPT, аккаунт, состоящий в 50+ каналах и активно постящий в них, — прямой кандидат на бан.

Мы добавили функцию count_account_channels() в pg_database.py и проверку лимита в _select_account():

async def count_account_channels(account_id: int) -> int:
    """Считает количество каналов, в которых активен аккаунт."""
    result = await db.fetchval(
        "SELECT COUNT(*) FROM account_channels "
        "WHERE account_id = $1 AND is_active = true",
        account_id
    )
    return result or 0
 
 
async def _select_account(step: SendStep) -> Account | None:
    """Выбирает подходящий аккаунт для отправки."""
    candidates = await get_available_accounts(step.channel_id)
    
    channel_limit = settings.get("MAX_CHANNELS_PER_ACCOUNT", 20)
    
    for account in candidates:
        # Geo-match
        geo_ok, geo_msg = validate_geo_match(account.phone, account.proxy_country)
        if not geo_ok:
            logger.warning(f"Skipping {account.id}: {geo_msg}")
            continue
        
        # Channel limit
        channel_count = await count_account_channels(account.id)
        if channel_count >= channel_limit:
            logger.info(f"Skipping {account.id}: channel limit {channel_count}/{channel_limit}")
            continue
        
        return account
    
    return None

Лимит по умолчанию — 20 каналов на аккаунт. Тоже вынесен в настройки БД.

Параллельный фронт: исправление импорта аккаунтов

Пока основная работа шла по плану anti-ban, всплыла практическая проблема: при попытке импортировать архив с аккаунтами с lolz.team получали ошибку:

'utf-8' codec can't decode byte 0xc8 in position 99: invalid continuation byte

Байт 0xc8 в cp1251 — это кириллическая буква «И». Архивы с lolz.team часто содержат JSON-файлы в кодировке Windows-1251, а Python 3 на Linux по умолчанию открывает файлы в UTF-8.

Фикс простой — детекция кодировки с fallback:

# До (сломанная версия)
with open(json_file) as f:
    meta = json.load(f)
 
# После (с поддержкой cp1251)
raw = json_file.read_bytes()
try:
    content = raw.decode("utf-8")
except UnicodeDecodeError:
    content = raw.decode("cp1251")
    logger.info(f"File {json_file.name} decoded as cp1251")
 
meta = json.loads(content)

Параллельно с фиксом кодировки объединили UI: вкладка «ZIP Импорт» была дублем основной страницы импорта, просто с другим форматом загрузки. Перенесли ZIP-загрузку как карточку на основную страницу /import и убрали лишний роут.

Результаты и метрики

  • 12 новых тестов — все зелёные. Каждая из четырёх задач покрыта отдельным тест-файлом (test_geo_match.py, test_ip_settle.py, test_typing.py, test_channel_limit.py).
  • Сервис tg-army-orchestrator запущен без ошибок импорта после деплоя.
  • Настройки вынесены в БД: IP_SETTLE_HOURS, MAX_CHANNELS_PER_ACCOUNT — можно менять без деплоя.
  • Импорт аккаунтов работает с архивами как в UTF-8, так и в cp1251.

Что касается банрейта — результаты увидим через 2-3 недели работы с новыми аккаунтами. По историческим данным, основные баны происходят в первые 48-72 часа после покупки, именно в этот период geo-match и IP settle delay работают наиболее критично.

Что ещё нашли в процессе исследования

Пока копались в теме anti-ban, наткнулись на блог GramGPT с интересным наблюдением про порядок действий при setup аккаунта.

Наш текущий pipeline: verify → profile/avatar/bio → 2FA → username → warmup → activate.

А рекомендованный порядок: отлежка 24-48ч → warmup 2-4 дня → ПОТОМ profile/avatar/bio.

Оказывается, мы делали профиль сразу после покупки — это один из главных триггеров заморозки. IP settle delay в 12 часов частично решает проблему, но не полностью: профиль всё равно правится до полноценного прогрева. Это следующий кандидат на рефакторинг pipeline.

Выводы и уроки

TDD окупается даже для «быстрых» задач. Казалось бы, добавить проверку гео — 20 строк кода, зачем тесты? Но именно тест на validate_geo_match поймал баг с разрешёнными комбинациями (RU→BY, RU→KZ) ещё до интеграции. Без теста мы бы заблокировали «разрешённые» аккаунты и даже не поняли бы почему.

Конфигурация в БД вместо хардкода — это не overengineering. Значения IP_SETTLE_HOURS=12 и MAX_CHANNELS_PER_ACCOUNT=20 — это гипотезы, не истины. Через две недели, посмотрев на данные, мы можем захотеть поставить 8 часов или 25 каналов. Если это в коде — нужен деплой. Если в БД — одна строчка SQL и перезапуск.

Маленькие UX-улучшения (убрать дублирующую вкладку) — это тоже технический долг. Дублирующийся роут /import_bulk создавал путаницу: «А где загружать ZIP? Там или тут?». Объединение в одну страницу — не большая задача, но это именно то, что накапливается и потом мешает новым разработчикам разобраться в системе.

Encoding-проблемы с внешними источниками данных — это классика. lolz.team — не первый и не последний сервис, который отдаёт файлы в Windows-1251. Паттерн «попробуй UTF-8, при ошибке попробуй cp1251» стоит иметь как утилиту в любом проекте, который работает с файлами от сторонних источников. То же самое касается csv-экспортов из 1С, архивов от российских госсервисов и ещё десятка источников, где cp1251 — норма жизни.

Изучение блогов конкурентов и смежных сервисов — полезная практика. Из анализа блога GramGPT мы получили конкретный инсайт про порядок setup, который у нас неправильный. Это не была задача из плана — это стало следующей задачей в бэклоге. Иногда лучшие идеи по улучшению продукта приходят не изнутри, а из внешнего анализа.

Технические ссылки

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

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