Files
cf7-spam-to-telegram/docs/superpowers/specs/2026-04-17-cf7-spam-to-telegram-design.md
Vladimir Bryzgalov 8efb82ca43 fix: phone leak, field-by-value detection, strict Flamingo email fallback
Three pilot-surfaced issues in one pass:

1. PhoneParser regex used '\s' in the candidate character class, so a
   digit on a following flattened line bled into the phone. '+7977
   6277470\n1' (phone + trailing checkbox value) parsed as 12 digits,
   producing '+797762774701'. Replaced '\s' with a literal space so
   newlines/tabs stop the candidate.

2. Custom-themed forms don't use 'your-name' / 'your-email' keys
   (washanyanya.ru has text-211 / text-212 / name_user). Added
   FieldDetector::find_name + find_email as fallbacks. Classifier
   and MessageFormatter now call them if no standard key matches,
   so 'Александр' in text-211 is recognised as a cyrillic name and
   scored / displayed accordingly.

3. FlamingoHelper copied '_from_email' into 'your-email' without
   validating it. Flamingo derives that meta from whatever field it
   considers "first"; on phone-first forms it ends up holding the
   phone number. Now we only copy _from_email if it contains '@',
   and dropped the _from_name shortcut entirely — FieldDetector
   handles the name via the actual field values.

Tests: +1 FieldDetectorTest (13 cases); +2 PhoneParserTest cases for
newline/tab bleeds; +1 ClassifierTest case; +1 MessageFormatterTest
case for custom field keys. Full suite 53/53 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:22:39 +05:00

28 KiB
Raw Permalink Blame History

CF7 Spam → Telegram — дизайн-документ

Дата: 2026-04-17 Автор: Владимир Брызгалов Статус: утверждён к планированию реализации


1. Цель и предпосылки

На сайтах Владимира (27 доменов на Beget, тематика — сиделки/няни/работа) через формы Contact Form 7 регулярно приходит спам. Встроенные фильтры (Akismet, honeypot, wpcf7_spam) отсекают его от обычного канала уведомлений — но среди отсечённых иногда бывают реальные заявки клиентов. Их теряют.

Цель плагина: пересылать в общую Telegram-группу все submissions CF7, помеченные как спам, с полным содержимым формы и эвристической оценкой «похоже клиент / неясно / похоже спам», чтобы менеджеры могли бегло просмотреть отсечённое и не упустить реальные обращения.

Не-цели:

  • Замена существующего плагина cf7-telegram (он остаётся и шлёт обычные «не-спам» заявки своим чередом).
  • Борьба со спамом (уменьшение количества) — плагин пассивный наблюдатель.
  • Двусторонний канал (ответ клиенту/пометка «не спам» из Telegram). Отложено на потенциальную v2.

2. Целевая среда

Параметр Значение
Хостинг Beget shared (пилот — washanyanya.ru, затем остальные 26 сайтов аккаунта pasha072)
WordPress 6.9.4+
PHP 7.4+ (все целевые сайты имеют доступ до 8.3)
Расширения PHP intl, curl, mbstring (проверено — есть на всех версиях Beget)
Зависимости Contact Form 7 + Flamingo (обязательны). Composer НЕ используется в проде
Локализация Только русский, строки хардкодятся без __()

3. Архитектурный выбор

Рассмотрены три подхода:

  1. Независимый плагин — собственная конфигурация, не зависит от cf7-telegram.
  2. Форк cf7-telegram — доработка чужой кодовой базы. Отклонён: дорого в поддержке на 27 сайтах, автообновления ломают форк.
  3. Надстройка над cf7-telegram — переиспользование его опций. Отклонён: он не предоставляет публичного API, залезание в его внутренности хрупко; нельзя разделить спам и обычный поток по разным чатам.

Выбран подход 1. Дублирование ~50 строк HTTP-кода приемлемо ради изоляции, независимой конфигурации и свободы выбора отдельной TG-группы для спама.

4. Слаг и идентификация

  • Слаг плагина / папка: cf7-spam-to-telegram
  • Текстовый префикс функций/опций/таблицы: cf7stg_
  • Префикс WP-фильтров/хуков расширения: cf7stg_
  • Константы wp-config (опциональные override'ы): SPAM2TG_BOT_TOKEN, SPAM2TG_CHAT_ID

5. Структура файлов

cf7-spam-to-telegram/
├── cf7-spam-to-telegram.php         # Главный файл с WP-заголовком, bootstrap
├── uninstall.php                     # Удаление опций и таблицы очереди
├── includes/
│   ├── class-plugin.php              # Singleton, регистрация хуков, автолоадер
│   ├── class-activator.php           # Создание таблицы, дефолтные опции
│   ├── class-settings.php            # Чтение/запись опций, приоритет констант
│   ├── class-crypto.php              # Шифрование токена через AUTH_KEY
│   ├── class-queue.php               # CRUD очереди, dedup, retention
│   ├── class-dispatcher.php          # WP-Cron тики, отправка, backoff
│   ├── class-telegram-client.php     # wp_remote_post, обработка кодов TG API
│   ├── class-telegram-exception.php  # TelegramException с флагом is_permanent
│   ├── class-classifier.php          # Эвристика, score → label, reasons
│   ├── class-phone-parser.php        # Нормализация RU-телефонов
│   ├── class-domain-formatter.php    # Punycode → кириллица (idn_to_utf8)
│   ├── class-field-detector.php      # Fallback поиск имени/email когда ключи формы нестандартны
│   ├── class-message-formatter.php   # Сборка HTML-сообщения
│   ├── class-retro-importer.php     # Ретро-выгрузка из Flamingo
│   ├── class-flamingo-helper.php    # Извлечение полей и reason из Flamingo
│   └── sources/
│       ├── interface-spam-source.php
│       └── class-cf7-source.php      # Хуки wpcf7_spam / wpcf7_submission
├── admin/
│   ├── class-settings-page.php       # Страница Настройки → CF7 Spam → TG
│   ├── class-dashboard-widget.php    # Виджет, показ только при ошибках
│   └── views/
│       ├── settings.php
│       └── dashboard-widget.php
├── tests/
│   ├── bootstrap.php                 # Автолоадер для юнит-тестов
│   ├── ClassifierTest.php
│   ├── PhoneParserTest.php
│   ├── DomainFormatterTest.php
│   └── MessageFormatterTest.php
├── composer.json                     # Только dev-зависимости (phpunit)
├── phpunit.xml
├── .gitignore
└── readme.txt                        # Стандартный WP readme (краткий)

Автолоадер: простой spl_autoload_register в class-plugin.php, маппинг Cf7stg\ClassNameincludes/class-class-name.php. Composer используется только для dev (PHPUnit).

6. Поток обработки входящего spam-события

┌────────────────────────────────────────────────────────────────┐
│ CF7 form submit                                                 │
└─────────────┬──────────────────────────────────────────────────┘
              ▼
   [ filter wpcf7_spam, priority 9999 ]          ←── основной триггер
              │
              │ если $spam === true:
              ▼
     CF7Source::handle_spam_submission($submission)
              │
              ├─► Flamingo сохраняет запись (priority 10,
              │   до или после нашего хука — неважно)
              │
              ▼
     ┌───────────────────────────────────────┐
     │ Сбор полей: $submission->get_posted_data() │
     │ Определение reason (Akismet/Honeypot/…)     │
     │ Извлечение IP, UA, UTM, Referer            │
     │ Classifier::classify(fields) → label       │
     │ MessageFormatter::build(...) → text + markup│
     │ Flamingo post ID (через Flamingo_Helper)   │
     │ Queue::enqueue(payload)                    │
     └───────────────────────────────────────┘
              │
              ▼
         return $spam (без изменений)

Fallback — action wpcf7_submission, priority 20: если $submission->get_status() === 'spam' и очередь ещё не содержит соответствующий flamingo_post_id — добавляем запись (страховка на случай honeypot'ов, которые ставят статус напрямую, минуя фильтр wpcf7_spam).

┌────────────────────────────────────────────────────────────────┐
│ WP-Cron (кастомный интервал every_minute)                       │
└─────────────┬──────────────────────────────────────────────────┘
              ▼
      Dispatcher::tick()
              │
              ▼
     Queue::fetch_pending(limit=5, where next_try_at <= NOW)
              │
              ▼
     Для каждой записи:
       TelegramClient::send_message(text, reply_markup)
       ├─ success → Queue::mark_sent(id, now)
       ├─ temp fail (5xx/429/network) → Queue::reschedule(id, attempts+1,
       │                                   backoff[60, 300, 1800, 3600])
       └─ perm fail (401/403/400 chat not found) или attempts>=5
                                            → Queue::mark_failed(id, err)

       usleep(100ms) между отправками (TG rate-limit ~30 msg/s)

7. Схема БД

Таблица {prefix}cf7stg_queue:

Поле Тип Комментарий
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
flamingo_post_id BIGINT UNSIGNED NULL id записи Flamingo; NULL если не создалась
form_id BIGINT UNSIGNED NULL id CF7-формы
source_key VARCHAR(40) NOT NULL 'cf7' (задел под другие источники)
payload_json LONGTEXT NOT NULL {text, reply_markup} в JSON
label VARCHAR(10) NOT NULL client / unclear / spam
is_retro TINYINT(1) NOT NULL DEFAULT 0 1 если из ретро-выгрузки
status VARCHAR(20) NOT NULL DEFAULT 'pending' pending / sent / failed
attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0
last_error TEXT NULL последняя ошибка TG
next_try_at DATETIME NULL для backoff
created_at DATETIME NOT NULL
sent_at DATETIME NULL

Индексы:

  • KEY idx_status_next (status, next_try_at) — для Dispatcher
  • UNIQUE KEY uq_flamingo (flamingo_post_id, is_retro) — dedup

Retention: ежедневный WP-Cron cf7stg_cleanup удаляет записи status='sent' старше 30 дней.

8. Classifier — эвристика

Метод: Classifier::classify(array $fields, array $meta): array возвращает:

[
    'score'   => int,                       // итоговый балл
    'label'   => 'client' | 'unclear' | 'spam',
    'reasons' => [                           // причины со знаком и весом
        ['sign' => '+', 'weight' => 3, 'text' => 'телефон RU'],
        ['sign' => '-', 'weight' => 2, 'text' => 'ключевое слово: SEO'],
        ...
    ],
]

Веса (дефолт):

Признак Вес
Телефон парсится как RU (+7/8, 10-11 цифр) +3
Имя — только кириллица, без цифр/URL +1
E-mail на ру-домене (yandex.ru, mail.ru, gmail.com, rambler.ru, ya.ru) +1
Текст — кириллица, > 10 симв., не одна ссылка +1
URL в любом поле (http://, https://, www.) 3
Ключевое слово (см. список) — 2 за вхождение, капается на 6 2 × min(n, 3)
Имя или текст — только латиница 1
Honeypot сработал (reason='Honeypot') 5 (и hard override: label=spam)

Ключевые слова (дефолт, матчинг по подстрокам через mb_stripos, регистронезависимо):

  • SEO/продвижение: SEO, СЕО, продвижение сайта, backlinks, обратные ссылки, позиции в поиске
  • Финансы: bitcoin, биткоин, crypto, криптовалют, forex, форекс, trading, трейдинг, loan, займ, микрозайм, инвестиции, заработок онлайн, удалённая работа без, MLM, сетевой маркетинг
  • Казино: casino, казино, ставки, betting, букмекер
  • БАДы/диеты: бад, биодобав, супер фуд, super food, детокс, похудение, похудет, диетическ, жиросжиг
  • Мед. препараты: viagra, виагра, cialis, сиалис, pharmacy, фарма, дженерик
  • Порно/эскорт: porn, xxx, adult, porno, порно, эскорт, проститу, секс-знакомств, интим-услуг

Пороги:

  • если reason === 'Honeypot'label = 'spam' (hard override, всегда)
  • иначе, если score >= 3label = 'client' (🟢 похоже клиент)
  • иначе, если score <= -2label = 'spam' (🔴 похоже спам)
  • иначе → label = 'unclear' (🟡 неясно)

Кастомизация — через WP-фильтры:

apply_filters('cf7stg_classifier_weights', $default_weights);
apply_filters('cf7stg_classifier_keywords', $default_keywords);
apply_filters('cf7stg_classifier_thresholds', ['client' => 3, 'spam' => -2]);

Поиск полей формы. Сначала пробуются известные CF7-ключи — your-name, name, имя, fio для имени; your-email, email, e-mail, mail для e-mail; your-message, message, comment, сообщение, вопрос для текста. Если ничего не нашлось (например, на сайте форма с кастомными именами полей text-211, name_user и т.п.), подключается FieldDetector:

  • find_name(fields) — первое значение без цифр/URL/@, длиной 2..80, состоящее только из letter-characters (любой скрипт), пробелов, дефисов и апострофов.
  • find_email(fields) — первое значение, матчащее ^\S+@\S+\.\S+$.

Телефон всегда ищется PhoneParser::parse() по конкатенации всех полей — имена ключей не важны.

9. MessageFormatter — формат сообщения в Telegram

  • parse_mode: HTML (строже и предсказуемее, чем MarkdownV2).
  • Все пользовательские значения прогоняются через esc_html() перед вставкой.
  • Лимит 4096 символов. Переполнение отсекается в теле 💬, остаток заменяется …[обрезано].
  • Для ретро-выгрузки добавляется префикс 📂 Ретроспектива · перед меткой классификации.

Шаблон:

{retro_prefix}{label_emoji} {label_text}
🌐 {site_title}  ·  форма «{form_title}»
🗓 {submitted_at}

📱 {phone_formatted | "(не указан)"}
👤 {name | "(не указано)"}
📧 {email | "(не указан)"}

💬 {message | "(пусто)"}

⚙️ Попало в спам: {reason}
📊 Классификация: {reasons_joined_by_comma}
🌍 IP {ip} · UA {user_agent_short}
{utm_block_if_any}
{other_fields_block_if_any}

Inline-клавиатура:

  • reply_markup = null всегда. Telegram Bot API отклоняет tel: URLs в inline_keyboard с ошибкой HTTP 400 Wrong port number specified in the URL. Номер показывается в теле сообщения (строка 📱 ...), мобильный TG-клиент автоматически делает его кликабельным.

Site title: Settings::get_site_title() возвращает либо оверрайд из настроек, либо декодированный через idn_to_utf8() host из home_url(). Например, xn--80aae0ca7d8bb.xn--p1aiвашаняня.рф.

10. Ретроспективная выгрузка

UI: на странице настроек — блок «Ретроспективная выгрузка из Flamingo»:

  • count (int, default 10, max 50)
  • days (int, default 0; 0 = без ограничения)
  • allow_repeat (checkbox)
  • кнопка «Добавить в очередь»

Обработка (синхронно при нажатии):

RetroImporter::import(int $count, int $days, bool $allow_repeat): array {
    // WP_Query по flamingo_inbound со статусом flamingo-spam,
    //   orderby=date DESC, posts_per_page=min(count, 50),
    //   фильтр по дате если $days > 0
    //
    // Для каждой записи:
    //   if (!$allow_repeat && Queue::exists_for_flamingo($post->ID)) skip;
    //   извлечь поля → Classifier → MessageFormatter (is_retro=true) → Queue::enqueue
    //
    // return ['added' => N, 'skipped' => M]
}

UI сообщает: «Добавлено 8, пропущено 2 (уже отправлено ранее)». Собственно доставка в TG — через общий WP-Cron цикл, в течение минуты.

11. Страница настроек

Путь: Настройки → CF7 Spam → Telegram (Settings API).

Поля:

  • Bot Token — password, шифруется при сохранении (см. §12). Если задан SPAM2TG_BOT_TOKEN — поле readonly с подсказкой.
  • Chat ID — text. Если задан SPAM2TG_CHAT_ID — readonly.
  • Заголовок сайта — text, пустое значение = авто-декодированный домен.
  • Кнопка «Отправить тестовое сообщение» — шлёт пробный текст в настроенный чат, показывает статус.
  • Блок «Ретроспективная выгрузка» — см. §10.
  • Блок «Состояние очереди» — счётчики pending/sent-за-сутки/failed + кнопки «Повторить все ошибочные» и «Очистить отправленные».

Capability: manage_options. Все POST-формы — с wp_nonce_field + проверка check_admin_referer.

12. Безопасность

  • Токен в БД шифруется алгоритмом openssl_encrypt('AES-256-CBC', ...), ключ — hash_hmac('sha256', 'cf7stg_token', AUTH_KEY), IV хранится рядом с шифротекстом. При чтении:
    • попытка дешифровать. Если ключ изменился (AUTH_KEY перевыпустили) — дешифровка упадёт; UI показывает «Токен повреждён, введите заново».
  • Константы wp-config (SPAM2TG_BOT_TOKEN, SPAM2TG_CHAT_ID) имеют приоритет над БД. Когда активны — поля в UI readonly.
  • Все входы админки — через capability manage_options + nonce.
  • Все внешние значения в TG-сообщениях — через esc_html().
  • TelegramClient — через wp_remote_post (автоматически использует настройки прокси WP, timeout=10s). Никаких curl_exec/fopen/exec.

13. WP-Cron

Регистрация при активации (register_activation_hook):

if (!wp_next_scheduled('cf7stg_dispatch')) {
    wp_schedule_event(time(), 'cf7stg_every_minute', 'cf7stg_dispatch');
}
if (!wp_next_scheduled('cf7stg_cleanup')) {
    wp_schedule_event(time() + 3600, 'daily', 'cf7stg_cleanup');
}

Кастомный интервал — через cron_schedules фильтр:

$schedules['cf7stg_every_minute'] = ['interval' => 60, 'display' => 'Раз в минуту (CF7 Spam → TG)'];

Деактивация (register_deactivation_hook): снимаем оба события через wp_unschedule_event.

Ограничения shared-хостинга: WP-Cron срабатывает при HTTP-запросах к сайту. При малом трафике события могут опаздывать. В UI настроек — короткая подсказка: «Для точности можно настроить внешний cron на wp-cron.php».

14. Dashboard-виджет

add_action('wp_dashboard_setup', ...) регистрирует виджет cf7stg_dashboard_status всегда (после пилота: пользователь ожидает увидеть быстрый статус, а не ориентироваться только на появление «тревожной» лампы).

Заголовок зависит от состояния:

  • failed > 0⚠️ CF7 Spam → Telegram: ошибки доставки
  • иначе → CF7 Spam → Telegram

Содержимое:

[если !is_configured: ⚙️ Плагин не настроен — укажите Bot Token и Chat ID в настройках.]

⏳ В очереди: N · ✅ За сутки: M · [✖|⚠️] Ошибки: K

[если K > 0:
Последние ошибки доставки (TG-API):
• {created_at} — {last_error}
• ...
]

[Открыть настройки →]

15. Обработка ошибок Telegram API

TelegramClient::send_message() бросает TelegramException, у которого метод is_permanent(): bool.

Перманентные (сразу failed, без ретраев):

  • HTTP 401 — токен неверный
  • HTTP 403 — бот забанен/удалён из группы
  • HTTP 400 + описание chat not found — неправильный chat_id
  • HTTP 400 + bot was blocked by the user (если кто-то случайно настроил личный чат)

Временные (ретрай с backoff):

  • HTTP 429 — чтимо retry_after из ответа (не меньше заданного backoff)
  • HTTP 500/502/503/504 — ошибка сервера TG
  • сетевой таймаут / WP_Error от wp_remote_post

Backoff: попытки 1→2→3→4→5 → паузы 60/300/1800/3600/3600 секунд. После 5 попыток — failed.

16. Тесты (PHPUnit, без WP)

Запуск: composer install, vendor/bin/phpunit. На проде composer не нужен, в .gitignorevendor/ и composer.lock.

Покрываются (целевые классы):

Класс Кейсов Темы
ClassifierTest ~15 реальная заявка → client; спам с URL+SEO → spam; honeypot перекрывает положительные сигналы; пустые поля → unclear; несколько ключевых слов капаются на −6; фильтры кастомизации
PhoneParserTest ~10 форматы +7(...)..., 8..., зарубежный → is_russian=false, несколько телефонов → первый, мусор → null
DomainFormatterTest ~5 xn-- → кириллица, обычные домены → без изменений, некорректный IDN → как есть без exception
MessageFormatterTest ~8 полный набор полей → ожидаемый HTML; без телефона → без inline-кнопки; длинное сообщение → обрезается; HTML-инъекция в поле → экранирование; префикс ретро

Не покрываются автотестами:

  • Queue, Dispatcher, CF7Source, TelegramClient, админ-страницы — требуют WP-окружения.
  • Проверяются вручную: кнопка «Тестовое сообщение», пилот на washanyanya.ru, отправка реальной спам-формы.

17. Жизненный цикл плагина

Activation (register_activation_hook):

  1. Проверка минимальной версии PHP (7.4) и WP (6.0+). При несоответствии — wp_die с понятным сообщением.
  2. Создание таблицы {prefix}cf7stg_queue через dbDelta.
  3. Установка дефолтных опций, если не заданы.
  4. Регистрация WP-Cron событий.

Deactivation (register_deactivation_hook):

  1. Снятие WP-Cron событий.
  2. Таблица и опции остаются (на случай реактивации).

Uninstall (uninstall.php):

  1. Удаление таблицы.
  2. Удаление всех cf7stg_* опций.

18. Деплой на сайты (за рамками MVP спеки)

Плагин — стандартный WP-плагин, упаковывается в .zip командой вида git archive или ручным zip-ом папки. Установка через wp-admin «Плагины → Добавить → Загрузить». Массовая раскатка на 27 сайтов — отдельная задача, в текущий спек не входит.

19. Открытые вопросы (решаем во время реализации)

  1. tel:-ссылки в inline-кнопках Telegram. РЕШЕНО (2026-04-17 пилот). Telegram Bot API отклонил tel: URLs (HTTP 400, Wrong port number specified in the URL). Inline-кнопки полностью убраны; номер показывается в теле сообщения, мобильные клиенты сами делают его кликабельным.
  2. Извлечение flamingo_post_id. Flamingo не даёт прямой ссылки submission→post после сохранения. План: после сохранения Flamingo (wpcf7_submission priority 20) искать в wp_posts записи post_type=flamingo_inbound, sort by date DESC, limit 1, фильтр по содержимому post_content (сравнение с полями submission). Если совпадение ненадёжное — плагин всё равно работает (flamingo_post_id = NULL, dedup ретро-выгрузки пропускается для этой записи, но основной поток не зависит от наличия id).
  3. Акцент HTML vs MarkdownV2. Если в процессе пилота выяснится, что клиенты часто пишут символы, ломающие HTML даже после экранирования — рассмотреть переход на plain text (без форматирования, эмодзи — unicode-символы, не требуют разметки).