From 3a998c36d28ed5a598292e57793203b97be4318b Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Fri, 17 Apr 2026 21:26:51 +0500 Subject: [PATCH] docs: add design spec for cf7-spam-to-telegram plugin Independent WP plugin that forwards CF7 spam submissions to a Telegram group, applying a weighted heuristic (client/unclear/spam) to help operators spot real inquiries mistakenly filtered as spam. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 6 + .../2026-04-17-cf7-spam-to-telegram-design.md | 401 ++++++++++++++++++ 2 files changed, 407 insertions(+) create mode 100644 .gitignore create mode 100644 docs/superpowers/specs/2026-04-17-cf7-spam-to-telegram-design.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fed5b02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +/node_modules/ +composer.lock +.phpunit.result.cache +.DS_Store +*.log diff --git a/docs/superpowers/specs/2026-04-17-cf7-spam-to-telegram-design.md b/docs/superpowers/specs/2026-04-17-cf7-spam-to-telegram-design.md new file mode 100644 index 0000000..9558c31 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-cf7-spam-to-telegram-design.md @@ -0,0 +1,401 @@ +# 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** | + +**Ключевые слова (дефолт, матчинг по подстрокам через `mb_stripos`, регистронезависимо):** + +- SEO/продвижение: `SEO`, `СЕО`, `продвижение сайта`, `backlinks`, `обратные ссылки`, `позиции в поиске` +- Финансы: `bitcoin`, `биткоин`, `crypto`, `криптовалют`, `forex`, `форекс`, `trading`, `трейдинг`, `loan`, `займ`, `микрозайм`, `инвестиции`, `заработок онлайн`, `удалённая работа без`, `MLM`, `сетевой маркетинг` +- Казино: `casino`, `казино`, `ставки`, `betting`, `букмекер` +- БАДы/диеты: `бад`, `биодобав`, `супер фуд`, `super food`, `детокс`, `похудение`, `похудет`, `диетическ`, `жиросжиг` +- Мед. препараты: `viagra`, `виагра`, `cialis`, `сиалис`, `pharmacy`, `фарма`, `дженерик` +- Порно/эскорт: `porn`, `xxx`, `adult`, `porno`, `порно`, `эскорт`, `проститу`, `секс-знакомств`, `интим-услуг` + +**Пороги:** +- `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--80aafmakjldfe1b5h.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-символы, не требуют разметки).