# 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-message-formatter.php # Сборка HTML-сообщения, inline-клавиатура │ ├── 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\ClassName` → `includes/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` возвращает: ```php [ '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 >= 3` → `label = 'client'` (🟢 похоже клиент) - иначе, если `score <= -2` → `label = 'spam'` (🔴 похоже спам) - иначе → `label = 'unclear'` (🟡 неясно) **Кастомизация** — через WP-фильтры: ```php apply_filters('cf7stg_classifier_weights', $default_weights); apply_filters('cf7stg_classifier_keywords', $default_keywords); apply_filters('cf7stg_classifier_thresholds', ['client' => 3, 'spam' => -2]); ``` ## 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-клавиатура:** - Если распарсился валидный RU-телефон — одна кнопка `"📞 Позвонить: {phone_formatted}"` → `url: "tel:{e164}"`. - Без телефона — `reply_markup = null` (кнопок нет). **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 7; 0 = без ограничения) - `allow_repeat` (checkbox) - кнопка «Добавить в очередь» **Обработка (синхронно при нажатии):** ```php 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`):** ```php 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` фильтр: ```php $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', ...)` → при `Queue::count_failed() > 0` регистрируется виджет `cf7stg_dashboard_errors`: ``` ⚠️ CF7 Spam → Telegram: ошибки доставки Есть N неотправленных сообщений (TG-API отвечает ошибкой): • {created_at} — {last_error} • ... [Открыть настройки →] ``` При `count_failed() == 0` — виджет не регистрируется, дашборд чистый. ## 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 не нужен, в `.gitignore` — `vendor/` и `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.** Bot API документация разрешает только `http/https/tg`, но практика показывает, что `tel:` работает в клиентах. Проверить на первом пилотном сайте; если Telegram отклонит — fallback: показать номер текстом без кнопки, добавить префикс `📞 ` для ясности. 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-символы, не требуют разметки).