287d313f1b
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
174 lines
15 KiB
Markdown
174 lines
15 KiB
Markdown
# CF7 Spam → Telegram — Silent Drop (1.1.0)
|
||
|
||
**Дата:** 2026-04-21
|
||
**Версия плагина:** 1.0.4 → 1.1.0
|
||
**Trigger:** на сайте домработница.рус через CF7 идут гарантированно мусорные заявки (имя из 2 латинских букв, пустые сообщения, поля заполнены 2-буквенными плейсхолдерами). Они помечаются Akismet как spam, попадают в Flamingo Spam и оттуда в TG-канал плагина. Владелец хочет, чтобы такие заявки **не отправлялись в TG вообще** — пользователь по-прежнему видит стандартный CF7 spam-message, заявка по-прежнему лежит в Flamingo Spam, но в Telegram-канале её нет.
|
||
|
||
## Scope
|
||
|
||
**В scope:**
|
||
- Новая метка Classifier `'drop'` (поверх `client/unclear/spam`).
|
||
- Hard-rule override (по правилам, минуя score) и soft-порог по score.
|
||
- Безопасный rollout через dry-run (по умолчанию ON после апгрейда).
|
||
- Счётчик дропов: dashboard widget + блок в Settings.
|
||
- Filter API для расширения извне.
|
||
|
||
**Вне scope:**
|
||
- Любые правки Akismet, Flamingo, основной формы CF7.
|
||
- Удаление заявок из Flamingo Spam.
|
||
- Чёрные списки IP/UA, ML-классификация, обучение на ретро.
|
||
- Авто-обновление плагина из git-сервера (выделено в отдельный эпик).
|
||
|
||
## Поведение
|
||
|
||
### Новая метка `drop`
|
||
|
||
Classifier возвращает `label='drop'`, если **выполнено хотя бы одно**:
|
||
|
||
1. **Hard-rule override** (если включён, дефолт ON):
|
||
У заявки **нет ни одного позитивного сигнала**:
|
||
- нет `cyrillic_name` (имя на кириллице),
|
||
- нет `meaningful_text` (осмысленный кириллический текст ≥ 10 символов в message-полях),
|
||
- нет валидного email где-либо в полях (проверка через `is_email()` от WP — любой синтаксически валидный, не только ru-домены, gmail.com тоже считается);
|
||
**И** хотя бы одно:
|
||
- имя (значение ключей `your-name`/`name`/`имя`/`fio` либо результат `FieldDetector::find_name()`) непустое и ≤ 3 символов из `[A-Za-z0-9]`,
|
||
- в форме ≥ 3 **непустых** полей имеют значение длиной ≤ 2 символов из `[A-Za-z0-9]` (паттерн ботов-плейсхолдеров; `acceptance-data` и подобные служебные ключи в подсчёт не входят — фильтруются по списку служебных префиксов).
|
||
|
||
2. **Score-порог** (всегда работает):
|
||
`score ≤ thresholds['drop']` (дефолт `-5`).
|
||
|
||
`'drop'` имеет приоритет: если hard-rule сработала, label='drop' независимо от score. Если score ≤ −5, label='drop' независимо от других правил.
|
||
|
||
### Поток обработки в `CF7Source::enqueue_from_submission`
|
||
|
||
```
|
||
CF7 submit (status=spam)
|
||
→ CF7Source::on_submission (приоритет 20)
|
||
→ Classifier::classify($fields, ['reason' => $reason])
|
||
→ label = result['label']
|
||
→ Если label === 'drop':
|
||
Если dry_run_drop ON:
|
||
Settings::increment_drop_counter('dry_run')
|
||
Queue::enqueue с payload['dry_run_drop']=true, label='drop'
|
||
Иначе:
|
||
Settings::increment_drop_counter('real')
|
||
return ← никакой записи в Queue, никаких хуков
|
||
→ Иначе (label != 'drop'):
|
||
Применяется filter cf7stg_should_enqueue(true, $label, $fields, $submission)
|
||
Если filter вернул false → return
|
||
Queue::enqueue как сейчас
|
||
```
|
||
|
||
### Что видит пользователь
|
||
**Drop, dry-run OFF:** стандартный CF7 spam-message на сайте, заявка в Flamingo Spam, в TG ничего.
|
||
|
||
**Drop, dry-run ON:** то же на сайте + сообщение в TG с заголовком `[БЫЛО БЫ DROPPED]` и строкой `Сработавшее правило: hard-rule (нет позитивных + короткое латинское имя)` или `Сработавшее правило: score ≤ -5`.
|
||
|
||
**Не drop:** всё работает как сейчас.
|
||
|
||
## Архитектура
|
||
|
||
### Изменяемые файлы
|
||
|
||
| Файл | Что меняется |
|
||
|---|---|
|
||
| `includes/class-classifier.php` | `DEFAULT_THRESHOLDS` += `'drop' => -5`. Новый метод `evaluate_hard_drop()` + private helpers `has_positive_signals()`, `has_short_latin_name()`, `count_placeholder_fields()`, `has_valid_email_anywhere()`. Логика возврата label расширена — `'drop'` имеет приоритет. Hard-rule конфигурируется через filter `cf7stg_hard_drop_rules`. |
|
||
| `includes/sources/class-cf7-source.php` | В `enqueue_from_submission()` добавляется ветка обработки `label === 'drop'` (см. data flow выше). Добавляется применение filter `cf7stg_should_enqueue` для не-drop случаев. |
|
||
| `includes/class-message-formatter.php` | Если `payload['dry_run_drop'] === true` — заголовок становится `[БЫЛО БЫ DROPPED]` (вместо текущего «Спам заявки для проверки»), добавляется строка с триггером правила. |
|
||
| `includes/class-settings.php` | Геттеры/сеттеры для новых опций. Метод `increment_drop_counter($mode)` с rolling сменой даты. Метод `reset_drop_counters()`. |
|
||
| `includes/class-activator.php` | На активации (как fresh install, так и upgrade до 1.1.0): `add_option('cf7stg_dry_run_drop', true)`, `add_option('cf7stg_drop_score_threshold', -5)`, `add_option('cf7stg_hard_drop_enabled', true)`, `add_option('cf7stg_drop_counters', $defaults)`. `add_option` сохранит существующее значение, если опция уже есть — то есть пользователи, уже выключившие dry-run, его не получат обратно. |
|
||
| `admin/class-settings-page.php` + `admin/views/settings.php` | Новый блок «Silent drop»: чекбокс dry-run, score-порог, чекбокс hard-rule, статистика, кнопка «Сбросить счётчики». |
|
||
| `admin/class-dashboard-widget.php` + `admin/views/dashboard-widget.php` | Строка со счётчиком дропов: при dry-run on — `Dry-run: за сегодня было бы дропнуто 3, всего 17`; при dry-run off — `Дропнуто сегодня: 3 (вчера 5, всего 124)`. Без эмодзи. |
|
||
| `cf7-spam-to-telegram.php` | `CF7STG_VERSION = '1.1.0'` в header и константе. |
|
||
| `readme.txt` | `Stable tag: 1.1.0` + запись в Changelog. |
|
||
|
||
### Новые опции WP
|
||
|
||
| Опция | Тип | Дефолт |
|
||
|---|---|---|
|
||
| `cf7stg_dry_run_drop` | bool | `true` |
|
||
| `cf7stg_drop_score_threshold` | int | `-5` |
|
||
| `cf7stg_hard_drop_enabled` | bool | `true` |
|
||
| `cf7stg_drop_counters` | array | `['total'=>0, 'real_today'=>0, 'real_yesterday'=>0, 'dry_today'=>0, 'dry_yesterday'=>0, 'today_date'=>'YYYY-MM-DD', 'reset_at'=>'YYYY-MM-DD']` |
|
||
|
||
### Filter API
|
||
|
||
| Filter | Аргументы | Назначение |
|
||
|---|---|---|
|
||
| `cf7stg_classifier_thresholds` *(уже есть)* | `array $thresholds` | Переопределить пороги (`client`, `spam`, `drop`). |
|
||
| `cf7stg_hard_drop_rules` *(новый)* | `array ['short_latin_name'=>bool, 'placeholder_fields'=>bool]` | Точечно отключать отдельные правила hard-rule. |
|
||
| `cf7stg_should_enqueue` *(новый)* | `bool $should, string $label, array $fields, $submission` | Вернуть `false` → не enqueue (override извне). Применяется только к НЕ-drop случаям; drop-кейсы решаются раньше. |
|
||
|
||
## Тесты
|
||
|
||
PHPUnit, suite `unit` (как сейчас). Все новые тесты — TDD (RED → GREEN → REFACTOR).
|
||
|
||
### `tests/ClassifierDropTest.php`
|
||
- `test_hard_drop_triggers_on_screenshot_1_full_form` — фикстура из реального скрина 1 → `label='drop'`, в `reasons` строка про hard-rule.
|
||
- `test_hard_drop_triggers_on_screenshot_2_full_form` — скрин 2.
|
||
- `test_hard_drop_triggers_on_screenshot_3_popup_form` — скрин 3.
|
||
- `test_hard_drop_skipped_when_cyrillic_name_present` — `your-name='Иван'` + остальное мусор → `label != 'drop'`.
|
||
- `test_hard_drop_skipped_when_meaningful_text_present` — содержательный кириллический message → `label != 'drop'`.
|
||
- `test_hard_drop_skipped_when_valid_email_present` — параметризованно: `pasha@gmail.com`, `info@some-domain.com`, `name@yandex.ru` → ни один не drop.
|
||
- `test_hard_drop_invalid_email_does_not_protect` — `email='not-an-email'` → drop, если остальные триггеры выполняются.
|
||
- `test_hard_drop_skipped_on_form_without_name_when_no_placeholder_rule` — нет name-полей и < 3 placeholder-полей → не drop (форма «обратный звонок»).
|
||
- `test_hard_drop_triggers_on_form_without_name_via_placeholder_rule` — нет name-полей, но 4 поля с 2-буквенным мусором → drop.
|
||
- `test_score_drop_threshold_minus_5` — синтетический набор → `label='drop'` через score.
|
||
- `test_filter_disables_hard_drop_rule_short_latin_name` — фильтр `cf7stg_hard_drop_rules` отключает `short_latin_name` → скрин 1 не падает (если другое правило не сработает).
|
||
- `test_filter_thresholds_can_change_drop_score` — фильтр меняет drop-порог на −3.
|
||
|
||
### `tests/SettingsCounterTest.php`
|
||
- `test_increment_real_counter` — пустой state → `total=1, real_today=1`.
|
||
- `test_increment_dry_run_counter` — то же для `'dry_run'`.
|
||
- `test_rolling_today_to_yesterday_on_date_change` — смена даты → `today` смещается в `yesterday`, обнуляется.
|
||
- `test_reset_counters_sets_new_reset_at` — reset обнуляет всё, ставит `reset_at = current_date()`.
|
||
|
||
### `tests/CF7SourceDropTest.php` (интеграция с моками)
|
||
- `test_drop_label_with_dry_run_off_does_not_enqueue` — мок Classifier=drop, dry-run off → `Queue::enqueue` НЕ вызван, `increment_drop_counter('real')` вызван 1 раз.
|
||
- `test_drop_label_with_dry_run_on_enqueues_with_marker` — то же, dry-run on → enqueue с `payload['dry_run_drop']=true`, `increment_drop_counter('dry_run')` вызван.
|
||
- `test_unclear_label_still_enqueues_normally` — регрессия.
|
||
- `test_filter_should_enqueue_can_block_non_drop` — фильтр для form_id=42 → false → не enqueue, счётчики не трогаются.
|
||
|
||
### `tests/MessageFormatterDryRunTest.php`
|
||
- `test_dry_run_drop_prefix_in_title` — `payload['dry_run_drop']=true` → текст начинается с `[БЫЛО БЫ DROPPED]`.
|
||
- `test_dry_run_drop_includes_triggered_rule_line` — есть строка `Сработавшее правило: ...`.
|
||
- `test_non_drop_payload_unchanged` — без флага вывод как раньше.
|
||
|
||
### Fixtures
|
||
`tests/fixtures/spam-screenshot-{1,2,3}.php` — массивы `$fields` ровно как в TG-сообщениях (text-785/787/786/788/789/790, text-347/tel-185, и т.п.). Используются и в Classifier-тестах, и для будущих ретро-проверок.
|
||
|
||
## Error handling
|
||
|
||
- `Classifier::evaluate_hard_drop()` — чистая функция от `$fields`, не бросает. Если `$fields` пустой/невалидный — возвращает false (нет drop).
|
||
- `Settings::increment_drop_counter()` — оборачивается в try/catch на уровне Source: если что-то упало, логируем через `error_log()` и продолжаем (не блокируем основной поток CF7).
|
||
- Если опция `cf7stg_drop_counters` повреждена/имеет неверную структуру — пересоздаётся из дефолта.
|
||
- Filter-возвраты валидируются: ожидаем bool/array; неверные типы → fallback к дефолту.
|
||
- Дефолт `dry_run_drop=true` — даже если admin никогда не открыл настройки, реальный drop не включится.
|
||
|
||
## Rollout
|
||
|
||
1. Установка/активация (fresh install **и** upgrade до 1.1.0) — Activator выставит `dry_run_drop=true`, `hard_drop_enabled=true`, `drop_score_threshold=-5`, пустые счётчики. Если опция уже была — её значение сохраняется (через `add_option`).
|
||
2. С момента активации:
|
||
- Все drop-заявки идут в TG с пометкой `[БЫЛО БЫ DROPPED]` (а не реально дропаются).
|
||
- Счётчик `dry_today` инкрементируется на каждом drop.
|
||
3. Через 1–2 недели владелец сайта смотрит TG-канал и счётчик в Settings — нет ли среди дропов реальных клиентов.
|
||
4. Если всё ок — снимает чекбокс «Dry-run mode» в настройках. С этого момента drop-заявки в TG не уходят, инкрементируется только `real_today`.
|
||
|
||
## Версионирование
|
||
|
||
- `1.0.4` → `1.1.0` (semver minor: новая фича, обратная совместимость).
|
||
- Changelog в `readme.txt`:
|
||
```
|
||
= 1.1.0 =
|
||
* New: Silent drop — заявки, удовлетворяющие hard-rule (нет позитивных
|
||
сигналов + короткое латинское имя или ≥3 полей-плейсхолдеров) или
|
||
score ≤ -5, не отправляются в TG. По умолчанию при установке/апгрейде
|
||
включён dry-run режим (помечает сообщение «[БЫЛО БЫ DROPPED]»);
|
||
реальный дроп включается чекбоксом в настройках.
|
||
* New: Settings → блок «Silent drop» с переключателями dry-run, hard-rule,
|
||
score-порогом и статистикой счётчиков.
|
||
* New: Dashboard widget показывает количество дропов.
|
||
* New: filter `cf7stg_should_enqueue` для внешних override-правил.
|
||
* New: filter `cf7stg_hard_drop_rules` для точечного отключения hard-rules.
|
||
```
|