Files
cf7-spam-to-telegram/docs/superpowers/specs/2026-04-17-cf7-spam-to-telegram-design.md
T
Vladimir Bryzgalov a7b796d381 docs: honeypot is a hard override on label=spam
Previous plan/spec made honeypot a -5 weighted signal only. That is
insufficient to force 'spam' when the submission also has a RU phone
(+3), cyrillic name (+1), and meaningful text (+1) — a clean +5 cancels
-5, yielding 'unclear'. The honeypot test (test_honeypot_forces_spam_
even_with_phone) caught this.

Honeypot semantics: a filled hidden field is a bot by definition.
No combination of positive signals can make it a legitimate client.
Keeping the -5 weight on 'reasons' list for transparency, but adding
a hard override that sets label='spam' at the end of classify().

Caught by TDD on Task 7.

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

403 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-символы, не требуют разметки).