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) <noreply@anthropic.com>
This commit is contained in:
Vladimir Bryzgalov
2026-04-17 21:26:51 +05:00
commit 3a998c36d2
2 changed files with 407 additions and 0 deletions
@@ -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-символы, не требуют разметки).