Files
cf7-spam-to-telegram/docs/superpowers/plans/2026-04-21-cf7stg-silent-drop.md
T
Vladimir Bryzgalov 9a55404918 docs: implementation plan for silent drop (1.1.0) — 14 tasks, TDD
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:37:42 +05:00

1774 lines
67 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 — Silent Drop (1.1.0) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Не отправлять в Telegram-канал заявки CF7, которые с высокой уверенностью являются ботовским мусором (новая метка `drop`), при этом оставив их в Flamingo Spam и сохранив для пользователя стандартный CF7 spam-message.
**Architecture:** Расширение `Classifier` новой меткой `drop` (через hard-rule override + score-порог), ранний return в `CF7Source::enqueue_from_submission` для drop-кейсов, dry-run переключатель + счётчик в `Settings`, помеченное сообщение в `MessageFormatter` в dry-run, UI-блок в Settings page и строка в Dashboard widget.
**Tech Stack:** PHP 7.4+, WordPress 6.0+, PHPUnit 9.6, Contact Form 7, Flamingo. Dev: composer, in-memory WP-shims в `tests/bootstrap.php`.
**Spec:** `docs/superpowers/specs/2026-04-21-cf7stg-silent-drop-design.md`
---
## Beads tracking
Перед началом работы создать в `bd` (из корня репозитория плагина) эпик + 12 подзадач, в `--type` указать `feature`/`task`, добавить `bd dep add <child> <epic> --type child-of` и между задачами цепочку `--type blocks` где есть жёсткая последовательность.
```bash
cd /Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка\ сайтов+/cf7-spam-to-telegram
bd create -t epic -p 1 -d "Silent drop (1.1.0): добавить метку drop, hard-rule, dry-run, счётчик. Спек: docs/superpowers/specs/2026-04-21-cf7stg-silent-drop-design.md" "Silent drop (1.1.0)"
# Запомнить выданный ID эпика, например cf7stg-1. Дальше для каждой Task ниже:
bd create -t task -p 2 -d "<краткое описание задачи>" "<title задачи>"
bd dep add <task-id> <epic-id> --type child-of
# Между Task N и Task N+1 (если N блокирует N+1):
bd dep add <task-(N+1)-id> <task-N-id> --type blocks
```
Перед стартом Task: `bd update <task-id> --claim`. После завершения: `bd close <task-id> --reason "Done"`.
---
## File Structure
| Файл | Создаётся / изменяется | Ответственность |
|---|---|---|
| `tests/bootstrap.php` | изменяется | Добавить WP-шимы со state: `add_filter`/`apply_filters`/`remove_all_filters`, `get_option`/`update_option`/`add_option`/`delete_option`, `is_email`, `current_time`, `wp_date`. |
| `tests/fixtures/spam-screenshot-1.php` | создаётся | Поля заявки из реального TG-скрина 1 (`text-785: OB, text-787: 70, text-786: BP, ...`). |
| `tests/fixtures/spam-screenshot-2.php` | создаётся | То же для скрина 2. |
| `tests/fixtures/spam-screenshot-3.php` | создаётся | То же для скрина 3 (popup-форма). |
| `tests/ClassifierDropTest.php` | создаётся | Все hard-rule кейсы + score threshold + filter override. |
| `tests/SettingsCounterTest.php` | создаётся | Тесты `increment_drop_counter`, rolling даты, reset. |
| `tests/CF7SourceDropTest.php` | создаётся | Тесты ветки `label='drop'` в `enqueue_from_submission`. |
| `tests/MessageFormatterDryRunTest.php` | создаётся | Тесты префикса `[БЫЛО БЫ DROPPED]` и строки триггера. |
| `includes/class-classifier.php` | изменяется | `DEFAULT_THRESHOLDS` += `'drop'`. Новый method `evaluate_hard_drop()`, helpers `has_short_latin_name()`, `count_placeholder_fields()`, `has_valid_email_anywhere()`. Логика label расширена. |
| `includes/class-settings.php` | изменяется | Константы `OPT_DRY_RUN_DROP`, `OPT_DROP_THRESHOLD`, `OPT_HARD_DROP_ENABLED`, `OPT_DROP_COUNTERS`. Геттеры/сеттеры. `increment_drop_counter($mode)`, `reset_drop_counters()`. |
| `includes/class-activator.php` | изменяется | В `activate()` дополнительный `seed_silent_drop_options()``add_option` для всех 4 новых опций (не перетирает existing). |
| `includes/sources/class-cf7-source.php` | изменяется | В `enqueue_from_submission()` ветка `label === 'drop'`: dry-run on → enqueue с маркером, off → return; общий filter `cf7stg_should_enqueue` для не-drop. |
| `includes/class-message-formatter.php` | изменяется | Если `payload['dry_run_drop']` — заголовок `[БЫЛО БЫ DROPPED]`, добавляется строка `Сработавшее правило: ...`. |
| `admin/views/settings.php` | изменяется | Новая секция «Silent drop»: dry-run чекбокс, score-порог number, hard-rule чекбокс, статистика, кнопка reset. |
| `admin/class-settings-page.php` | изменяется | Новые actions `cf7stg_save_drop`, `cf7stg_reset_drop_counters`. Расширить notice-обработку. |
| `admin/class-dashboard-widget.php` | изменяется | Передавать в view `dry_run_on`, `drop_counters`. |
| `admin/views/dashboard-widget.php` | изменяется | Строка со счётчиком (формат зависит от dry-run on/off). |
| `cf7-spam-to-telegram.php` | изменяется | `Version: 1.1.0` в header, `CF7STG_VERSION = '1.1.0'`. |
| `readme.txt` | изменяется | `Stable tag: 1.1.0` + запись в Changelog. |
---
## Task 1: Расширить тестовые WP-шимы
**Files:**
- Modify: `tests/bootstrap.php`
Цель: дать всем последующим тестам реальные `add_filter`/`apply_filters`/`get_option`/`update_option` с состоянием — без них тесты для Settings/CF7Source/Classifier-фильтров писать невозможно. Текущие шимы — pass-through, их недостаточно.
- [ ] **Step 1.1: Полностью переписать `tests/bootstrap.php`**
```php
<?php
declare(strict_types=1);
// Autoloader (без изменений)
spl_autoload_register(static function (string $class): void {
if (strpos($class, 'Cf7stg\\') !== 0) {
return;
}
$relative = substr($class, strlen('Cf7stg\\'));
$slug = strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1-$2', $relative));
$slug = str_replace('_', '-', $slug);
$path = __DIR__ . '/../includes/class-' . $slug . '.php';
if (is_file($path)) {
require_once $path;
}
});
// Stateful WP shims
final class Cf7stgWpShimState
{
/** @var array<string,mixed> */
public static array $options = [];
/** @var array<string,array<int,array{cb:callable,priority:int,accepted_args:int}>> */
public static array $filters = [];
public static function reset(): void
{
self::$options = [];
self::$filters = [];
}
}
if (!function_exists('apply_filters')) {
function apply_filters($tag, $value, ...$args) {
if (!isset(Cf7stgWpShimState::$filters[$tag])) {
return $value;
}
$callbacks = Cf7stgWpShimState::$filters[$tag];
usort($callbacks, static fn($a, $b) => $a['priority'] <=> $b['priority']);
foreach ($callbacks as $f) {
$cb_args = array_slice(array_merge([$value], $args), 0, $f['accepted_args']);
$value = call_user_func_array($f['cb'], $cb_args);
}
return $value;
}
}
if (!function_exists('add_filter')) {
function add_filter($tag, $cb, $priority = 10, $accepted_args = 1) {
Cf7stgWpShimState::$filters[$tag][] = [
'cb' => $cb, 'priority' => (int)$priority, 'accepted_args' => (int)$accepted_args,
];
return true;
}
}
if (!function_exists('remove_all_filters')) {
function remove_all_filters($tag, $priority = false) {
unset(Cf7stgWpShimState::$filters[$tag]);
return true;
}
}
if (!function_exists('add_action')) {
function add_action($tag, $cb, $priority = 10, $accepted_args = 1) {
return add_filter($tag, $cb, $priority, $accepted_args);
}
}
if (!function_exists('do_action')) {
function do_action($tag, ...$args) {
apply_filters($tag, null, ...$args);
}
}
if (!function_exists('get_option')) {
function get_option($key, $default = false) {
return Cf7stgWpShimState::$options[$key] ?? $default;
}
}
if (!function_exists('update_option')) {
function update_option($key, $value) {
Cf7stgWpShimState::$options[$key] = $value;
return true;
}
}
if (!function_exists('add_option')) {
function add_option($key, $value) {
if (array_key_exists($key, Cf7stgWpShimState::$options)) {
return false;
}
Cf7stgWpShimState::$options[$key] = $value;
return true;
}
}
if (!function_exists('delete_option')) {
function delete_option($key) {
unset(Cf7stgWpShimState::$options[$key]);
return true;
}
}
if (!function_exists('is_email')) {
function is_email($email) {
$email = (string)$email;
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return false;
}
return $email;
}
}
if (!function_exists('current_time')) {
function current_time($format) {
if ($format === 'mysql') return date('Y-m-d H:i:s');
return date($format);
}
}
if (!function_exists('wp_date')) {
function wp_date($format, $timestamp = null) {
return date($format, $timestamp ?? time());
}
}
if (!function_exists('esc_html')) {
function esc_html($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
}
if (!function_exists('esc_url')) {
function esc_url($s) { return filter_var((string)$s, FILTER_SANITIZE_URL); }
}
if (!function_exists('wp_strip_all_tags')) {
function wp_strip_all_tags($s) { return trim(strip_tags((string)$s)); }
}
if (!function_exists('__')) {
function __($text) { return $text; }
}
if (!function_exists('wp_json_encode')) {
function wp_json_encode($v) { return json_encode($v, JSON_UNESCAPED_UNICODE); }
}
```
- [ ] **Step 1.2: Запустить весь существующий suite — проверить что регрессий нет**
Run: `vendor/bin/phpunit`
Expected: все существующие тесты `ClassifierTest`, `DomainFormatterTest`, `FieldDetectorTest`, `MessageFormatterTest`, `PhoneParserTest` проходят (зелёные). Если что-то упало — исправить шимы; не двигаться дальше.
- [ ] **Step 1.3: Закоммитить**
```bash
git add tests/bootstrap.php
git commit -m "test: stateful WP shims (add_filter/get_option/is_email) for upcoming silent-drop tests"
```
---
## Task 2: Фикстуры spam-screenshot-1/2/3
**Files:**
- Create: `tests/fixtures/spam-screenshot-1.php`
- Create: `tests/fixtures/spam-screenshot-2.php`
- Create: `tests/fixtures/spam-screenshot-3.php`
- [ ] **Step 2.1: Создать `tests/fixtures/spam-screenshot-1.php`**
```php
<?php
// Реальная заявка из TG (домработница.рус, 21.04.2026 11:52, форма «Форма (детальная)»)
return [
'text-785' => 'OB', // имя
'text-787' => '70',
'text-786' => 'BP',
'text-788' => 'ZZ',
'text-789' => 'QV',
'text-790' => '6994740787', // телефон
'acceptance-data' => '1',
];
```
- [ ] **Step 2.2: Создать `tests/fixtures/spam-screenshot-2.php`**
```php
<?php
// Реальная заявка из TG (домработница.рус, 21.04.2026 09:40, форма «Форма (детальная)»)
return [
'text-785' => 'UE',
'text-787' => '11',
'text-786' => 'IU',
'text-788' => 'AE',
'text-789' => 'TE',
'text-790' => '7870034693',
'acceptance-data' => '1',
];
```
- [ ] **Step 2.3: Создать `tests/fixtures/spam-screenshot-3.php`**
```php
<?php
// Реальная заявка из TG (домработница.рус, 21.04.2026 07:10, форма «Всплывающая форма (расчёт)»)
return [
'text-347' => 'MU',
'tel-185' => '2682217194',
'acceptance-data' => '1',
'text-subject' => 'Найти домработницу в Москве от агентства «Домработница.рус»',
'text-title' => 'What websites do you visit most often?',
];
```
- [ ] **Step 2.4: Закоммитить**
```bash
git add tests/fixtures/
git commit -m "test(fixtures): real spam submissions from TG screenshots (3 cases)"
```
---
## Task 3: Helper `has_valid_email_anywhere()` в Classifier
**Files:**
- Modify: `includes/class-classifier.php`
- Modify: `tests/ClassifierDropTest.php` (создаётся пустым каркасом + первый тест)
- Create: `tests/ClassifierDropTest.php`
Цель: добавить чистую функцию определения «есть ли валидный email хоть где-то в полях». Используется ТОЛЬКО для drop-логики (не влияет на текущий score).
- [ ] **Step 3.1: Создать `tests/ClassifierDropTest.php` с failing-тестом**
```php
<?php
declare(strict_types=1);
namespace Cf7stg\Tests;
use Cf7stg\Classifier;
use PHPUnit\Framework\TestCase;
final class ClassifierDropTest extends TestCase
{
protected function setUp(): void
{
\Cf7stgWpShimState::reset();
}
public function test_has_valid_email_anywhere_finds_gmail(): void
{
$fields = ['some-key' => 'pasha@gmail.com'];
self::assertTrue(Classifier::has_valid_email_anywhere($fields));
}
public function test_has_valid_email_anywhere_finds_in_array_value(): void
{
$fields = ['x' => ['noise', 'info@some-domain.com']];
self::assertTrue(Classifier::has_valid_email_anywhere($fields));
}
public function test_has_valid_email_anywhere_returns_false_for_invalid(): void
{
$fields = ['email' => 'not-an-email', 'x' => 'AB'];
self::assertFalse(Classifier::has_valid_email_anywhere($fields));
}
public function test_has_valid_email_anywhere_returns_false_when_empty(): void
{
self::assertFalse(Classifier::has_valid_email_anywhere([]));
}
}
```
- [ ] **Step 3.2: Запустить — убедиться что падает**
Run: `vendor/bin/phpunit --filter ClassifierDropTest`
Expected: FAIL — `Classifier::has_valid_email_anywhere` does not exist.
- [ ] **Step 3.3: Реализовать метод в `includes/class-classifier.php`**
Добавить в `final class Classifier` (после `keyword_hits`):
```php
public static function has_valid_email_anywhere(array $fields): bool
{
foreach ($fields as $v) {
$values = is_array($v) ? $v : [$v];
foreach ($values as $val) {
$val = trim((string)$val);
if ($val === '') continue;
if (preg_match('/[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/', $val, $m)) {
if (is_email($m[0])) return true;
}
}
}
return false;
}
```
- [ ] **Step 3.4: Запустить — убедиться что зелёный**
Run: `vendor/bin/phpunit --filter ClassifierDropTest`
Expected: PASS — все 4 теста зелёные.
- [ ] **Step 3.5: Закоммитить**
```bash
git add includes/class-classifier.php tests/ClassifierDropTest.php
git commit -m "feat(classifier): has_valid_email_anywhere() helper for drop-logic"
```
---
## Task 4: Helper `has_short_latin_name()` в Classifier
**Files:**
- Modify: `includes/class-classifier.php`
- Modify: `tests/ClassifierDropTest.php`
- [ ] **Step 4.1: Добавить тесты в `tests/ClassifierDropTest.php`**
```php
public function test_has_short_latin_name_true_for_two_letters(): void
{
$fields = ['your-name' => 'OB'];
self::assertTrue(Classifier::has_short_latin_name($fields));
}
public function test_has_short_latin_name_true_for_three_alnum(): void
{
$fields = ['name' => 'A1B'];
self::assertTrue(Classifier::has_short_latin_name($fields));
}
public function test_has_short_latin_name_false_for_long_name(): void
{
$fields = ['your-name' => 'Melissa'];
self::assertFalse(Classifier::has_short_latin_name($fields));
}
public function test_has_short_latin_name_false_for_cyrillic(): void
{
$fields = ['your-name' => 'Иван'];
self::assertFalse(Classifier::has_short_latin_name($fields));
}
public function test_has_short_latin_name_false_when_no_name_field(): void
{
// Только телефон, нет name-полей и через FieldDetector тоже ничего
$fields = ['tel-1' => '+79991234567'];
self::assertFalse(Classifier::has_short_latin_name($fields));
}
public function test_has_short_latin_name_picks_via_field_detector(): void
{
// text-211 не в списке известных name-ключей, но FieldDetector найдёт по эвристике
$fields = ['text-211' => 'XY', 'text-212' => '+79991234567'];
self::assertTrue(Classifier::has_short_latin_name($fields));
}
```
- [ ] **Step 4.2: Запустить — убедиться что падает**
Run: `vendor/bin/phpunit --filter has_short_latin_name`
Expected: FAIL — метод не существует.
- [ ] **Step 4.3: Реализовать**
Добавить в Classifier (рядом с `has_valid_email_anywhere`):
```php
public static function has_short_latin_name(array $fields): bool
{
$name = self::pick_field($fields, ['your-name', 'name', 'имя', 'fio']);
if ($name === '') $name = FieldDetector::find_name($fields);
$name = trim($name);
if ($name === '') return false;
return (bool)preg_match('/^[A-Za-z0-9]{1,3}$/', $name);
}
```
- [ ] **Step 4.4: Запустить**
Run: `vendor/bin/phpunit --filter ClassifierDropTest`
Expected: PASS — все тесты зелёные. Если `picks_via_field_detector` упал — посмотреть `FieldDetector::find_name()` и при необходимости адаптировать тест (значение должно действительно вытаскиваться эвристикой).
- [ ] **Step 4.5: Закоммитить**
```bash
git add includes/class-classifier.php tests/ClassifierDropTest.php
git commit -m "feat(classifier): has_short_latin_name() helper for drop-logic"
```
---
## Task 5: Helper `count_placeholder_fields()` в Classifier
**Files:**
- Modify: `includes/class-classifier.php`
- Modify: `tests/ClassifierDropTest.php`
- [ ] **Step 5.1: Добавить тесты**
```php
public function test_count_placeholder_fields_screenshot_1(): void
{
$fields = require __DIR__ . '/fixtures/spam-screenshot-1.php';
// text-785: OB(2), text-787: 70(2), text-786: BP(2), text-788: ZZ(2), text-789: QV(2)
// text-790: 10 цифр — не placeholder. acceptance-data: служебное.
self::assertGreaterThanOrEqual(3, Classifier::count_placeholder_fields($fields));
}
public function test_count_placeholder_fields_ignores_empty(): void
{
$fields = ['a' => 'AB', 'b' => '', 'c' => 'XY', 'd' => null, 'e' => 'CD'];
self::assertSame(3, Classifier::count_placeholder_fields($fields));
}
public function test_count_placeholder_fields_ignores_long_values(): void
{
$fields = ['a' => 'AB', 'b' => 'Hello world', 'c' => 'CD'];
self::assertSame(2, Classifier::count_placeholder_fields($fields));
}
public function test_count_placeholder_fields_ignores_service_keys(): void
{
$fields = [
'acceptance-data' => '1',
'_wpcf7' => 'AB',
'_wpcf7_unit_tag' => 'XY',
'g-recaptcha-response' => 'CD',
'real' => 'EF',
];
self::assertSame(1, Classifier::count_placeholder_fields($fields));
}
public function test_count_placeholder_fields_ignores_non_alnum(): void
{
// Кириллица или с пробелами не считается placeholder
$fields = ['a' => 'Ия', 'b' => 'a b', 'c' => 'XY'];
self::assertSame(1, Classifier::count_placeholder_fields($fields));
}
```
- [ ] **Step 5.2: Запустить — FAIL**
Run: `vendor/bin/phpunit --filter count_placeholder_fields`
Expected: FAIL.
- [ ] **Step 5.3: Реализовать**
Добавить в Classifier:
```php
/** Service field keys that must be excluded from placeholder count. */
private const SERVICE_FIELD_KEYS = [
'acceptance-data',
'_wpcf7', '_wpcf7_version', '_wpcf7_locale', '_wpcf7_unit_tag',
'_wpcf7_container_post', '_wpcf7_posted_data_hash',
'g-recaptcha-response',
];
public static function count_placeholder_fields(array $fields): int
{
$count = 0;
foreach ($fields as $k => $v) {
if (is_string($k)) {
if (in_array($k, self::SERVICE_FIELD_KEYS, true)) continue;
if (strpos($k, '_wpcf7') === 0) continue;
}
if (is_array($v)) $v = implode('', array_map('strval', $v));
$v = trim((string)$v);
if ($v === '') continue;
if (!preg_match('/^[A-Za-z0-9]{1,2}$/', $v)) continue;
$count++;
}
return $count;
}
```
- [ ] **Step 5.4: Запустить — PASS**
Run: `vendor/bin/phpunit --filter ClassifierDropTest`
Expected: все тесты зелёные.
- [ ] **Step 5.5: Закоммитить**
```bash
git add includes/class-classifier.php tests/ClassifierDropTest.php
git commit -m "feat(classifier): count_placeholder_fields() helper for drop-logic"
```
---
## Task 6: `evaluate_hard_drop()` + новый label `drop` + порог `drop`
**Files:**
- Modify: `includes/class-classifier.php`
- Modify: `tests/ClassifierDropTest.php`
Цель: интегрировать helpers в `Classifier::classify`, добавить порог по score, обеспечить приоритет label='drop'.
- [ ] **Step 6.1: Добавить интеграционные тесты — все три скрина → drop**
```php
public function test_screenshot_1_yields_drop(): void
{
$fields = require __DIR__ . '/fixtures/spam-screenshot-1.php';
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertSame('drop', $r['label']);
}
public function test_screenshot_2_yields_drop(): void
{
$fields = require __DIR__ . '/fixtures/spam-screenshot-2.php';
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertSame('drop', $r['label']);
}
public function test_screenshot_3_yields_drop(): void
{
$fields = require __DIR__ . '/fixtures/spam-screenshot-3.php';
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertSame('drop', $r['label']);
}
public function test_drop_skipped_when_cyrillic_name_present(): void
{
$fields = [
'your-name' => 'Иван',
'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF',
];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertNotSame('drop', $r['label']);
}
public function test_drop_skipped_when_meaningful_text_present(): void
{
$fields = [
'your-name' => 'AB',
'your-message' => 'Здравствуйте, нужна уборка квартиры в субботу с 10 утра',
'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF',
];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertNotSame('drop', $r['label']);
}
/** @dataProvider valid_email_provider */
public function test_drop_skipped_when_valid_email_present(string $email): void
{
$fields = [
'your-name' => 'AB',
'your-email' => $email,
'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF',
];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertNotSame('drop', $r['label']);
}
public function valid_email_provider(): array
{
return [
'gmail' => ['pasha@gmail.com'],
'doменный' => ['info@some-domain.com'],
'yandex' => ['name@yandex.ru'],
];
}
public function test_drop_invalid_email_does_not_protect(): void
{
$fields = [
'your-name' => 'OB',
'email' => 'not-an-email',
'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF',
];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertSame('drop', $r['label']);
}
public function test_drop_skipped_on_form_without_name_when_no_placeholder_rule(): void
{
// Форма «обратный звонок»: только телефон, имя нет, < 3 placeholder
$fields = ['tel-1' => '+79991234567'];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertNotSame('drop', $r['label']);
}
public function test_drop_triggers_on_form_without_name_via_placeholder_rule(): void
{
$fields = ['a' => 'AB', 'b' => 'CD', 'c' => 'EF', 'd' => 'GH'];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertSame('drop', $r['label']);
}
public function test_score_drop_threshold_minus_5(): void
{
// keyword_cap (-6) + latin_only (-1) = -7, без позитивных → score-based drop
$fields = [
'your-name' => 'Melissa Smith',
'your-message' => 'SEO, backlinks, crypto, casino, займ, bitcoin, viagra, porno',
];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertLessThanOrEqual(-5, $r['score']);
self::assertSame('drop', $r['label']);
}
public function test_filter_disables_short_latin_name_rule(): void
{
add_filter('cf7stg_hard_drop_rules', static function ($rules) {
$rules['short_latin_name'] = false;
return $rules;
});
// Только короткое имя, < 3 placeholder → правило отключено → не drop
$fields = ['your-name' => 'OB', 'tel' => '+79991234567'];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
self::assertNotSame('drop', $r['label']);
}
public function test_filter_thresholds_can_change_drop_score(): void
{
add_filter('cf7stg_classifier_thresholds', static function ($t) {
$t['drop'] = -3;
return $t;
});
// score = -3 (URL: -3) и нет позитивных
$fields = ['msg' => 'http://x.com'];
$r = Classifier::classify($fields, ['reason' => 'Akismet']);
// URL даёт -3, нет hard-rule (не подходит ни короткое имя, ни placeholder), но -3 ≤ -3
self::assertSame('drop', $r['label']);
}
```
- [ ] **Step 6.2: Запустить — FAIL (label не существует)**
Run: `vendor/bin/phpunit --filter ClassifierDropTest`
Expected: большинство падают потому что метка `'drop'` не возвращается.
- [ ] **Step 6.3: Изменить `Classifier::DEFAULT_THRESHOLDS`**
В файле `includes/class-classifier.php` заменить:
```php
public const DEFAULT_THRESHOLDS = [
'client' => 3,
'spam' => -2,
'drop' => -5,
];
```
- [ ] **Step 6.4: Добавить `evaluate_hard_drop()`**
Добавить в Classifier:
```php
public const DEFAULT_HARD_DROP_RULES = [
'short_latin_name' => true,
'placeholder_fields' => true,
];
public static function evaluate_hard_drop(array $fields): bool
{
$rules = apply_filters('cf7stg_hard_drop_rules', self::DEFAULT_HARD_DROP_RULES);
// Любой позитивный сигнал — НЕ drop
$name = self::pick_field($fields, ['your-name', 'name', 'имя', 'fio']);
if ($name === '') $name = FieldDetector::find_name($fields);
if ($name !== '' && self::is_cyrillic_name($name)) return false;
$message = self::pick_field($fields, ['your-message', 'message', 'comment', 'сообщение', 'вопрос']);
if ($message !== '' && self::is_meaningful_text($message)) return false;
if (self::has_valid_email_anywhere($fields)) return false;
// Триггеры
if (!empty($rules['short_latin_name']) && self::has_short_latin_name($fields)) return true;
if (!empty($rules['placeholder_fields']) && self::count_placeholder_fields($fields) >= 3) return true;
return false;
}
```
- [ ] **Step 6.5: Расширить логику возврата label в `classify()`**
В методе `classify()`, заменить блок определения label (строки ~131-141):
```php
$label = 'unclear';
if ($score >= (int)$thresholds['client']) {
$label = 'client';
} elseif ($score <= (int)$thresholds['spam']) {
$label = 'spam';
}
// Honeypot is a hard override
if ($honeypot_tripped) {
$label = 'spam';
}
// Drop has highest priority
$is_drop = self::evaluate_hard_drop($fields);
if (!$is_drop && isset($thresholds['drop']) && $score <= (int)$thresholds['drop']) {
$is_drop = true;
}
if ($is_drop) {
$label = 'drop';
$reasons[] = ['sign' => '×', 'weight' => 0, 'text' => self::drop_reason_label($fields, $score, $thresholds)];
}
return ['score' => $score, 'label' => $label, 'reasons' => $reasons];
}
private static function drop_reason_label(array $fields, int $score, array $thresholds): string
{
if (self::evaluate_hard_drop($fields)) {
$reasons = [];
if (self::has_short_latin_name($fields)) $reasons[] = 'короткое латинское имя';
if (self::count_placeholder_fields($fields) >= 3) $reasons[] = '≥3 поля-плейсхолдера';
return 'hard-rule: ' . implode(' + ', $reasons);
}
return 'score ≤ ' . (int)$thresholds['drop'];
}
```
- [ ] **Step 6.6: Запустить — PASS**
Run: `vendor/bin/phpunit --filter ClassifierDropTest`
Expected: все 13+ тестов зелёные.
Run: `vendor/bin/phpunit` (полный suite — регрессия)
Expected: все остальные тесты тоже зелёные.
- [ ] **Step 6.7: Закоммитить**
```bash
git add includes/class-classifier.php tests/ClassifierDropTest.php
git commit -m "feat(classifier): label 'drop' via hard-rule + score threshold (-5 default)"
```
---
## Task 7: Settings — новые опции и счётчик
**Files:**
- Modify: `includes/class-settings.php`
- Create: `tests/SettingsCounterTest.php`
- [ ] **Step 7.1: Создать `tests/SettingsCounterTest.php`**
```php
<?php
declare(strict_types=1);
namespace Cf7stg\Tests;
use Cf7stg\Settings;
use PHPUnit\Framework\TestCase;
final class SettingsCounterTest extends TestCase
{
protected function setUp(): void
{
\Cf7stgWpShimState::reset();
}
public function test_default_counters_are_zero(): void
{
$c = Settings::get_drop_counters();
self::assertSame(0, $c['total']);
self::assertSame(0, $c['real_today']);
self::assertSame(0, $c['dry_today']);
}
public function test_increment_real_counter(): void
{
Settings::increment_drop_counter('real');
$c = Settings::get_drop_counters();
self::assertSame(1, $c['total']);
self::assertSame(1, $c['real_today']);
self::assertSame(0, $c['dry_today']);
}
public function test_increment_dry_run_counter(): void
{
Settings::increment_drop_counter('dry_run');
Settings::increment_drop_counter('dry_run');
$c = Settings::get_drop_counters();
self::assertSame(2, $c['total']);
self::assertSame(0, $c['real_today']);
self::assertSame(2, $c['dry_today']);
}
public function test_rolling_today_to_yesterday_on_date_change(): void
{
// Bootstrap: устанавливаем today_date = вчера
$yesterday = date('Y-m-d', strtotime('-1 day'));
update_option(Settings::OPT_DROP_COUNTERS, [
'total' => 5,
'real_today' => 5,
'real_yesterday' => 0,
'dry_today' => 0,
'dry_yesterday' => 0,
'today_date' => $yesterday,
'reset_at' => $yesterday,
]);
Settings::increment_drop_counter('real');
$c = Settings::get_drop_counters();
self::assertSame(date('Y-m-d'), $c['today_date']);
self::assertSame(5, $c['real_yesterday']);
self::assertSame(1, $c['real_today']);
self::assertSame(6, $c['total']);
}
public function test_reset_counters_zeroes_and_updates_reset_at(): void
{
Settings::increment_drop_counter('real');
Settings::increment_drop_counter('dry_run');
Settings::reset_drop_counters();
$c = Settings::get_drop_counters();
self::assertSame(0, $c['total']);
self::assertSame(0, $c['real_today']);
self::assertSame(0, $c['dry_today']);
self::assertSame(date('Y-m-d'), $c['reset_at']);
}
public function test_get_dry_run_drop_default_true_when_unset(): void
{
// Опция не установлена ещё — геттер возвращает true как safe default
self::assertTrue(Settings::is_dry_run_drop());
}
public function test_get_dry_run_drop_false_when_disabled(): void
{
update_option(Settings::OPT_DRY_RUN_DROP, false);
self::assertFalse(Settings::is_dry_run_drop());
}
public function test_get_drop_score_threshold_default_minus_5(): void
{
self::assertSame(-5, Settings::get_drop_score_threshold());
}
public function test_get_hard_drop_enabled_default_true(): void
{
self::assertTrue(Settings::is_hard_drop_enabled());
}
public function test_invalid_counters_option_resets_to_defaults(): void
{
update_option(Settings::OPT_DROP_COUNTERS, 'broken-string');
$c = Settings::get_drop_counters();
self::assertSame(0, $c['total']);
self::assertArrayHasKey('today_date', $c);
}
}
```
- [ ] **Step 7.2: Запустить — FAIL**
Run: `vendor/bin/phpunit --filter SettingsCounterTest`
Expected: FAIL — нет констант, нет методов.
- [ ] **Step 7.3: Расширить `includes/class-settings.php`**
В начало класса добавить новые константы:
```php
public const OPT_DRY_RUN_DROP = 'cf7stg_dry_run_drop';
public const OPT_DROP_THRESHOLD = 'cf7stg_drop_score_threshold';
public const OPT_HARD_DROP_ENABLED = 'cf7stg_hard_drop_enabled';
public const OPT_DROP_COUNTERS = 'cf7stg_drop_counters';
```
В конец класса добавить методы:
```php
public static function is_dry_run_drop(): bool
{
return (bool)get_option(self::OPT_DRY_RUN_DROP, true);
}
public static function set_dry_run_drop(bool $on): void
{
update_option(self::OPT_DRY_RUN_DROP, $on);
}
public static function get_drop_score_threshold(): int
{
return (int)get_option(self::OPT_DROP_THRESHOLD, -5);
}
public static function set_drop_score_threshold(int $v): void
{
update_option(self::OPT_DROP_THRESHOLD, $v);
}
public static function is_hard_drop_enabled(): bool
{
return (bool)get_option(self::OPT_HARD_DROP_ENABLED, true);
}
public static function set_hard_drop_enabled(bool $on): void
{
update_option(self::OPT_HARD_DROP_ENABLED, $on);
}
public static function get_drop_counters(): array
{
$raw = get_option(self::OPT_DROP_COUNTERS, null);
if (!is_array($raw) || !isset($raw['total'])) {
return self::default_drop_counters();
}
return array_merge(self::default_drop_counters(), $raw);
}
public static function increment_drop_counter(string $mode): void
{
if (!in_array($mode, ['real', 'dry_run'], true)) return;
$c = self::get_drop_counters();
$today = date('Y-m-d');
if ($c['today_date'] !== $today) {
$c['real_yesterday'] = $c['real_today'];
$c['dry_yesterday'] = $c['dry_today'];
$c['real_today'] = 0;
$c['dry_today'] = 0;
$c['today_date'] = $today;
}
$c['total']++;
if ($mode === 'real') {
$c['real_today']++;
} else {
$c['dry_today']++;
}
update_option(self::OPT_DROP_COUNTERS, $c);
}
public static function reset_drop_counters(): void
{
update_option(self::OPT_DROP_COUNTERS, self::default_drop_counters());
}
private static function default_drop_counters(): array
{
$today = date('Y-m-d');
return [
'total' => 0,
'real_today' => 0,
'real_yesterday' => 0,
'dry_today' => 0,
'dry_yesterday' => 0,
'today_date' => $today,
'reset_at' => $today,
];
}
```
- [ ] **Step 7.4: Запустить — PASS**
Run: `vendor/bin/phpunit --filter SettingsCounterTest`
Expected: все тесты зелёные.
Run: `vendor/bin/phpunit` (регрессия)
Expected: всё зелёное.
- [ ] **Step 7.5: Закоммитить**
```bash
git add includes/class-settings.php tests/SettingsCounterTest.php
git commit -m "feat(settings): silent-drop options + counter with rolling today→yesterday"
```
---
## Task 8: Activator — seed safe defaults
**Files:**
- Modify: `includes/class-activator.php`
Цель: при активации (fresh install + upgrade) выставить безопасные дефолты для silent-drop. `add_option` сохранит существующие значения, если опции уже есть.
- [ ] **Step 8.1: Изменить `Activator::activate()`**
В файле `includes/class-activator.php` заменить `activate()`:
```php
public static function activate(): void
{
self::guard_requirements();
self::install_table();
self::seed_silent_drop_options();
self::schedule_events();
}
```
И добавить новый метод:
```php
private static function seed_silent_drop_options(): void
{
add_option(Settings::OPT_DRY_RUN_DROP, true);
add_option(Settings::OPT_DROP_THRESHOLD, -5);
add_option(Settings::OPT_HARD_DROP_ENABLED, true);
add_option(Settings::OPT_DROP_COUNTERS, [
'total' => 0,
'real_today' => 0,
'real_yesterday' => 0,
'dry_today' => 0,
'dry_yesterday' => 0,
'today_date' => date('Y-m-d'),
'reset_at' => date('Y-m-d'),
]);
}
```
- [ ] **Step 8.2: Регрессия PHPUnit**
Run: `vendor/bin/phpunit`
Expected: всё зелёное (Activator без unit-теста — он вызывает `dbDelta` и `wp_die`, ручная проверка позже).
- [ ] **Step 8.3: Закоммитить**
```bash
git add includes/class-activator.php
git commit -m "feat(activator): seed silent-drop options (dry-run on by default) on activate"
```
---
## Task 9: CF7Source — обработка label='drop' + filter `cf7stg_should_enqueue`
**Files:**
- Modify: `includes/sources/class-cf7-source.php`
- Create: `tests/CF7SourceDropTest.php`
- [ ] **Step 9.1: Создать тестовый submission-стаб**
В `tests/CF7SourceDropTest.php`:
```php
<?php
declare(strict_types=1);
namespace Cf7stg\Tests;
use Cf7stg\CF7Source;
use Cf7stg\Settings;
use PHPUnit\Framework\TestCase;
/** Минимальная имитация WPCF7_Submission. */
final class FakeSubmission
{
/** @var array<string,mixed> */
private array $data;
private string $status;
private ?string $spam_log;
public function __construct(array $data, string $status = 'spam', ?string $spam_log = null)
{
$this->data = $data;
$this->status = $status;
$this->spam_log = $spam_log;
}
public function get_status(): string { return $this->status; }
public function get_posted_data(): array { return $this->data; }
public function get_spam_log() { return $this->spam_log ?? []; }
public function get_contact_form() { return new FakeForm(); }
}
final class FakeForm
{
public function id(): int { return 42; }
public function title(): string { return 'Test form'; }
}
/** Spy для Queue::enqueue — заменяет реальный класс через автозагрузчик в bootstrap. */
final class CF7SourceDropTest extends TestCase
{
/** @var array<int,array> */
public static array $enqueued = [];
/** @var array<int,string> */
public static array $counter_calls = [];
protected function setUp(): void
{
\Cf7stgWpShimState::reset();
self::$enqueued = [];
self::$counter_calls = [];
// Перехват Queue::enqueue и Settings::increment_drop_counter через runkit
// ...либо через jednу из техник: переопределить классы перед запуском теста.
// Поскольку runkit обычно не доступен, используем filter cf7stg_should_enqueue
// как ОТСЕЧКУ внутрь Queue: добавим фильтр, который запоминает попытку и
// возвращает false (чтобы реальный Queue::enqueue не вызывался).
add_filter('cf7stg_test_capture_enqueue', static function ($payload) {
self::$enqueued[] = $payload;
return $payload;
});
}
// Подход к тестам: Queue::enqueue в проде делает INSERT в WP БД, которой в тестах нет.
// Поэтому в этом suite мы тестируем САМОГО CF7Source через приватную обёртку
// CF7Source::should_enqueue_for_label() и CF7Source::handle_drop() — обе будут
// вынесены как публичные методы при реализации.
public function test_drop_label_with_dry_run_off_returns_skip_decision(): void
{
Settings::set_dry_run_drop(false);
$decision = CF7Source::decide_drop_action('drop');
self::assertSame('skip', $decision['action']);
self::assertSame('real', $decision['counter_mode']);
}
public function test_drop_label_with_dry_run_on_returns_enqueue_with_marker(): void
{
Settings::set_dry_run_drop(true);
$decision = CF7Source::decide_drop_action('drop');
self::assertSame('enqueue_marked', $decision['action']);
self::assertSame('dry_run', $decision['counter_mode']);
}
public function test_non_drop_label_returns_normal_enqueue(): void
{
$decision = CF7Source::decide_drop_action('unclear');
self::assertSame('enqueue', $decision['action']);
self::assertNull($decision['counter_mode']);
}
public function test_filter_should_enqueue_can_block_unclear(): void
{
add_filter('cf7stg_should_enqueue', static function ($should, $label) {
return $label === 'unclear' ? false : $should;
}, 10, 2);
self::assertFalse(CF7Source::should_enqueue('unclear', [], null));
}
public function test_filter_should_enqueue_default_true(): void
{
self::assertTrue(CF7Source::should_enqueue('client', [], null));
}
}
```
- [ ] **Step 9.2: Запустить — FAIL (методы не существуют)**
Run: `vendor/bin/phpunit --filter CF7SourceDropTest`
Expected: FAIL.
- [ ] **Step 9.3: Расширить `includes/sources/class-cf7-source.php`**
Добавить два публичных хелпера + изменить `enqueue_from_submission`:
```php
/**
* @return array{action:'enqueue'|'enqueue_marked'|'skip',counter_mode:?string}
*/
public static function decide_drop_action(string $label): array
{
if ($label !== 'drop') {
return ['action' => 'enqueue', 'counter_mode' => null];
}
if (Settings::is_dry_run_drop()) {
return ['action' => 'enqueue_marked', 'counter_mode' => 'dry_run'];
}
return ['action' => 'skip', 'counter_mode' => 'real'];
}
public static function should_enqueue(string $label, array $fields, $submission): bool
{
return (bool)apply_filters('cf7stg_should_enqueue', true, $label, $fields, $submission);
}
```
И изменить `enqueue_from_submission` (после получения `$classification`, перед `$post_id = ...`):
```php
$label = $classification['label'];
$decision = self::decide_drop_action($label);
if ($decision['action'] === 'skip') {
Settings::increment_drop_counter($decision['counter_mode']);
return;
}
if (!self::should_enqueue($label, $fields, $submission)) {
return;
}
$post_id = FlamingoHelper::find_post_id_for_submission($submission);
if ($post_id !== null && Queue::exists_for_flamingo($post_id, false)) {
return;
}
$ip = $this->remote_addr();
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? (string)$_SERVER['HTTP_USER_AGENT'] : '';
$payload = MessageFormatter::build([
'fields' => $fields,
'classification' => $classification,
'site_title' => Settings::get_site_title(),
'form_title' => $form_title,
'submitted_at' => wp_date('d.m.Y H:i'),
'reason' => $reason,
'ip' => $ip,
'user_agent' => $ua,
'is_retro' => false,
'dry_run_drop' => $decision['action'] === 'enqueue_marked',
]);
if ($decision['action'] === 'enqueue_marked') {
Settings::increment_drop_counter($decision['counter_mode']);
}
Queue::enqueue([
'flamingo_post_id' => $post_id,
'form_id' => $form_id,
'source_key' => 'cf7',
'payload_json' => wp_json_encode($payload),
'label' => $label,
'is_retro' => 0,
]);
}
```
- [ ] **Step 9.4: Запустить — PASS**
Run: `vendor/bin/phpunit --filter CF7SourceDropTest`
Expected: все тесты зелёные.
Run: `vendor/bin/phpunit` (регрессия)
Expected: всё зелёное.
- [ ] **Step 9.5: Закоммитить**
```bash
git add includes/sources/class-cf7-source.php tests/CF7SourceDropTest.php
git commit -m "feat(source): drop label → skip enqueue (or marked enqueue in dry-run); cf7stg_should_enqueue filter"
```
---
## Task 10: MessageFormatter — пометка `[БЫЛО БЫ DROPPED]`
**Files:**
- Modify: `includes/class-message-formatter.php`
- Create: `tests/MessageFormatterDryRunTest.php`
- [ ] **Step 10.1: Создать `tests/MessageFormatterDryRunTest.php`**
```php
<?php
declare(strict_types=1);
namespace Cf7stg\Tests;
use Cf7stg\MessageFormatter;
use PHPUnit\Framework\TestCase;
final class MessageFormatterDryRunTest extends TestCase
{
protected function setUp(): void
{
\Cf7stgWpShimState::reset();
}
private function ctx(array $overrides = []): array
{
return array_merge([
'fields' => ['your-name' => 'OB', 'tel' => '+79991234567'],
'classification' => [
'score' => 2,
'label' => 'drop',
'reasons' => [
['sign' => '+', 'weight' => 3, 'text' => 'телефон RU'],
['sign' => '×', 'weight' => 0, 'text' => 'hard-rule: короткое латинское имя'],
],
],
'site_title' => 'домработница.рус',
'form_title' => 'Test',
'submitted_at' => '21.04.2026 11:52',
'reason' => 'Akismet',
'ip' => '91.188.244.33',
'user_agent' => 'Mozilla/5.0',
'is_retro' => false,
'dry_run_drop' => false,
], $overrides);
}
public function test_dry_run_drop_prefix_in_first_line(): void
{
$r = MessageFormatter::build($this->ctx(['dry_run_drop' => true]));
$first_line = strtok($r['text'], "\n");
self::assertStringContainsString('[БЫЛО БЫ DROPPED]', $first_line);
}
public function test_dry_run_drop_includes_triggered_rule_line(): void
{
$r = MessageFormatter::build($this->ctx(['dry_run_drop' => true]));
self::assertStringContainsString('Сработавшее правило: hard-rule: короткое латинское имя', $r['text']);
}
public function test_non_drop_payload_no_marker(): void
{
$r = MessageFormatter::build($this->ctx(['dry_run_drop' => false]));
self::assertStringNotContainsString('[БЫЛО БЫ DROPPED]', $r['text']);
self::assertStringNotContainsString('Сработавшее правило', $r['text']);
}
}
```
- [ ] **Step 10.2: Запустить — FAIL**
Run: `vendor/bin/phpunit --filter MessageFormatterDryRunTest`
Expected: FAIL.
- [ ] **Step 10.3: Изменить `MessageFormatter::build()`**
Заменить блок с `LABEL_MAP` и формированием `$header` (строки ~10-49):
Добавить новую константу:
```php
private const LABEL_MAP = [
'client' => ['emoji' => '🟢', 'text' => 'похоже клиент'],
'unclear' => ['emoji' => '🟡', 'text' => 'неясно'],
'spam' => ['emoji' => '🔴', 'text' => 'похоже спам'],
'drop' => ['emoji' => '🟡', 'text' => 'неясно'],
];
```
(label `drop` в обычном режиме сюда не попадает — отбрасывается до `MessageFormatter`. Но в dry-run попадает; используем нейтральный жёлтый, потому что заголовок будет переопределён.)
Заменить блок построения header:
```php
$label_info = self::LABEL_MAP[$class['label']] ?? self::LABEL_MAP['unclear'];
$is_dry_run_drop = !empty($ctx['dry_run_drop']);
if ($is_dry_run_drop) {
$header = '[БЫЛО БЫ DROPPED]';
} else {
$header = ($ctx['is_retro'] ? '📂 Ретроспектива · ' : '')
. $label_info['emoji'] . ' ' . $label_info['text'];
}
$lines[] = $header;
```
И после строки `'📊 Классификация: ' . self::format_reasons(...)` добавить (внутри метода `build`):
```php
if ($is_dry_run_drop) {
$rule = '';
foreach ($class['reasons'] as $r) {
if (strpos((string)$r['text'], 'hard-rule:') === 0 || strpos((string)$r['text'], 'score ≤') === 0) {
$rule = (string)$r['text'];
break;
}
}
if ($rule !== '') $lines[] = '🚫 Сработавшее правило: ' . esc_html($rule);
}
```
- [ ] **Step 10.4: Запустить — PASS**
Run: `vendor/bin/phpunit --filter MessageFormatterDryRunTest`
Expected: PASS.
Run: `vendor/bin/phpunit` (регрессия — `MessageFormatterTest` особенно)
Expected: всё зелёное.
- [ ] **Step 10.5: Закоммитить**
```bash
git add includes/class-message-formatter.php tests/MessageFormatterDryRunTest.php
git commit -m "feat(formatter): [БЫЛО БЫ DROPPED] header + triggered-rule line in dry-run mode"
```
---
## Task 11: Settings page — блок «Silent drop»
**Files:**
- Modify: `admin/class-settings-page.php`
- Modify: `admin/views/settings.php`
Без unit-тестов (UI). Ручная проверка после деплоя.
- [ ] **Step 11.1: Добавить два новых action handler в `Settings_Page::register()`**
В `register()` после `add_action('admin_post_cf7stg_purge_sent', ...)`:
```php
add_action('admin_post_cf7stg_save_drop', [$this, 'handle_save_drop']);
add_action('admin_post_cf7stg_reset_drop_counters', [$this, 'handle_reset_drop_counters']);
```
И сами методы (после `handle_purge_sent`):
```php
public function handle_save_drop(): void
{
$this->guard('cf7stg_save_drop');
Settings::set_dry_run_drop(!empty($_POST['dry_run_drop']));
Settings::set_hard_drop_enabled(!empty($_POST['hard_drop_enabled']));
$threshold = (int)($_POST['drop_score_threshold'] ?? -5);
Settings::set_drop_score_threshold($threshold);
$this->redirect_back(['drop_saved' => 1]);
}
public function handle_reset_drop_counters(): void
{
$this->guard('cf7stg_reset_drop_counters');
Settings::reset_drop_counters();
$this->redirect_back(['drop_reset' => 1]);
}
```
- [ ] **Step 11.2: Добавить блок в `admin/views/settings.php`**
После строки `if (isset($_GET['purged']))` добавить:
```php
if (isset($_GET['drop_saved'])) $notice = '<div class="notice notice-success"><p>Настройки silent drop сохранены.</p></div>';
if (isset($_GET['drop_reset'])) $notice = '<div class="notice notice-success"><p>Счётчики silent drop сброшены.</p></div>';
```
И перед закрывающим `</div>` (т.е. в самом конце шаблона перед строкой 111) вставить:
```php
<hr>
<h2>Silent drop</h2>
<p class="description">
Заявки, удовлетворяющие правилам, не отправляются в Telegram.
Подробности правил в <code>docs/superpowers/specs/2026-04-21-cf7stg-silent-drop-design.md</code>.
</p>
<?php
$drop_counters = \Cf7stg\Settings::get_drop_counters();
$is_dry = \Cf7stg\Settings::is_dry_run_drop();
?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="cf7stg_save_drop">
<?php wp_nonce_field('cf7stg_save_drop', $nonce); ?>
<table class="form-table">
<tr>
<th>Dry-run mode</th>
<td>
<label>
<input type="checkbox" name="dry_run_drop" value="1" <?php checked($is_dry); ?>>
Не дропать реально, отправлять в TG с пометкой «[БЫЛО БЫ DROPPED]»
</label>
<p class="description">Включён по умолчанию. Снимите галочку только убедившись по TG-сообщениям, что среди дропов нет реальных клиентов.</p>
</td>
</tr>
<tr>
<th>Hard-rule override</th>
<td>
<label>
<input type="checkbox" name="hard_drop_enabled" value="1" <?php checked(\Cf7stg\Settings::is_hard_drop_enabled()); ?>>
Включить правило: нет позитивных сигналов + короткое латинское имя или ≥ 3 поля-плейсхолдера
</label>
</td>
</tr>
<tr>
<th><label for="drop_score_threshold">Score-порог drop</label></th>
<td>
<input type="number" id="drop_score_threshold" name="drop_score_threshold" value="<?php echo (int)\Cf7stg\Settings::get_drop_score_threshold(); ?>" step="1">
<p class="description">Дефолт 5. Заявка с score ≤ этого значения дропается даже без hard-rule.</p>
</td>
</tr>
</table>
<?php submit_button('Сохранить silent-drop настройки'); ?>
</form>
<h3>Статистика</h3>
<p>
Всего дропнуто: <b><?php echo (int)$drop_counters['total']; ?></b><br>
Сегодня (real): <b><?php echo (int)$drop_counters['real_today']; ?></b> · сегодня (dry-run): <b><?php echo (int)$drop_counters['dry_today']; ?></b><br>
Вчера (real): <b><?php echo (int)$drop_counters['real_yesterday']; ?></b> · вчера (dry-run): <b><?php echo (int)$drop_counters['dry_yesterday']; ?></b><br>
Счётчик с: <b><?php echo esc_html((string)$drop_counters['reset_at']); ?></b>
</p>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="cf7stg_reset_drop_counters">
<?php wp_nonce_field('cf7stg_reset_drop_counters', $nonce); ?>
<button type="submit" class="button">Сбросить счётчики</button>
</form>
```
- [ ] **Step 11.3: Регрессия**
Run: `vendor/bin/phpunit`
Expected: всё зелёное.
- [ ] **Step 11.4: Закоммитить**
```bash
git add admin/class-settings-page.php admin/views/settings.php
git commit -m "feat(admin): silent-drop block in settings page (dry-run, hard-rule, threshold, stats, reset)"
```
---
## Task 12: Dashboard widget — строка со счётчиком
**Files:**
- Modify: `admin/class-dashboard-widget.php`
- Modify: `admin/views/dashboard-widget.php`
- [ ] **Step 12.1: Передать новые переменные во view**
В `Dashboard_Widget::render()` (после `$configured = ...`):
```php
$dry_run_on = Settings::is_dry_run_drop();
$drop_counters = Settings::get_drop_counters();
```
- [ ] **Step 12.2: Добавить строку во view**
В `admin/views/dashboard-widget.php` после блока с pending/sent_24h/failed (после строки `</p>` ~21) вставить:
```php
<?php /** @var bool $dry_run_on */ /** @var array $drop_counters */ ?>
<p>
<?php if ($dry_run_on): ?>
Dry-run: за сегодня было бы дропнуто <b><?php echo (int)$drop_counters['dry_today']; ?></b>, всего <b><?php echo (int)$drop_counters['total']; ?></b>.
<?php else: ?>
Дропнуто сегодня: <b><?php echo (int)$drop_counters['real_today']; ?></b> (вчера <?php echo (int)$drop_counters['real_yesterday']; ?>, всего <?php echo (int)$drop_counters['total']; ?>).
<?php endif; ?>
</p>
```
И в шапке файла к docblock-аннотациям добавить:
```php
/** @var bool $dry_run_on */
/** @var array $drop_counters */
```
- [ ] **Step 12.3: Регрессия**
Run: `vendor/bin/phpunit`
Expected: всё зелёное.
- [ ] **Step 12.4: Закоммитить**
```bash
git add admin/class-dashboard-widget.php admin/views/dashboard-widget.php
git commit -m "feat(admin): silent-drop counter line in dashboard widget"
```
---
## Task 13: Bump version 1.1.0 + changelog
**Files:**
- Modify: `cf7-spam-to-telegram.php`
- Modify: `readme.txt`
- [ ] **Step 13.1: Версия в bootstrap-файле**
В `cf7-spam-to-telegram.php` заменить:
```php
* Version: 1.1.0
```
и
```php
define('CF7STG_VERSION', '1.1.0');
```
- [ ] **Step 13.2: Stable tag и changelog в `readme.txt`**
Заменить `Stable tag: 1.0.4` на `Stable tag: 1.1.0`.
В разделе `== Changelog ==` сразу после строки `== Changelog ==` (перед `= 1.0.4 =`) вставить:
```
= 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.
```
- [ ] **Step 13.3: Закоммитить**
```bash
git add cf7-spam-to-telegram.php readme.txt
git commit -m "chore: bump version to 1.1.0 with silent-drop changelog"
```
---
## Task 14: Smoke test на сайте + финальный merge
**Files:** none (ручная проверка + git)
Цель: убедиться что плагин работает на реальном сайте до merge в `main`.
- [ ] **Step 14.1: Финальная регрессия PHPUnit**
Run: `vendor/bin/phpunit`
Expected: все тесты зелёные. Если что-то падает — НЕ мержить, разобраться.
- [ ] **Step 14.2: Деплой ветки на тестовый/продовый сайт**
Скопировать всю папку плагина (исключая `.git`, `tests`, `docs`, `vendor`, `composer.*`, `phpunit.xml`, `.phpunit.result.cache`, `.beads`, `.claude`, `AGENTS.md`, `CLAUDE.md`) на сервер `sidelkin-ssh:/home/p/pasha072/domramotnica.rus/public_html/wp-content/plugins/cf7-spam-to-telegram/`. Например:
```bash
rsync -av --delete \
--exclude='.git' --exclude='tests' --exclude='docs' --exclude='vendor' \
--exclude='composer.json' --exclude='composer.lock' --exclude='phpunit.xml' \
--exclude='.phpunit.result.cache' --exclude='.beads' --exclude='.claude' \
--exclude='AGENTS.md' --exclude='CLAUDE.md' --exclude='.gitignore' --exclude='.gitattributes' \
./ sidelkin-ssh:/home/p/pasha072/domramotnica.rus/public_html/wp-content/plugins/cf7-spam-to-telegram/
```
- [ ] **Step 14.3: Деактивировать и активировать плагин**
В админке домработница.рус: «Плагины» → найти CF7 Spam → Telegram → деактивировать → активировать. Это запустит `Activator::activate()``seed_silent_drop_options()` → проставит дефолты.
- [ ] **Step 14.4: Проверить опции через WP-CLI на сервере**
```bash
ssh sidelkin-ssh "cd /home/p/pasha072/domramotnica.rus/public_html && \
php8.3 /usr/local/bin/wp-cli.phar option get cf7stg_dry_run_drop && \
php8.3 /usr/local/bin/wp-cli.phar option get cf7stg_drop_score_threshold && \
php8.3 /usr/local/bin/wp-cli.phar option get cf7stg_hard_drop_enabled && \
php8.3 /usr/local/bin/wp-cli.phar option get cf7stg_drop_counters --format=json"
```
Expected: `1` / `-5` / `1` / валидный JSON со счётчиками = 0.
- [ ] **Step 14.5: Открыть Settings page и Dashboard widget — глазами проверить**
Открыть в браузере `…/wp-admin/options-general.php?page=cf7stg-settings`. Видим блок «Silent drop» с галкой dry-run (включена), порогом -5, статистикой 0/0/0.
Открыть `…/wp-admin/`. В виджете «CF7 Spam → Telegram» строка `Dry-run: за сегодня было бы дропнуто 0, всего 0`.
- [ ] **Step 14.6: Дождаться или сэмулировать spam-заявку**
Идеально — дождаться следующего бота на форме «Форма (детальная)». Альтернатива: вручную отправить заявку через форму с именем `OB`, телефоном `+7 (699) 474-07-87` (фейковый), без email/сообщения, заполняя `text-787/788/789` 2-буквенным мусором.
В Telegram должно прийти сообщение с заголовком `[БЫЛО БЫ DROPPED]` и строкой `🚫 Сработавшее правило: hard-rule: ...`. Счётчик `dry_today` инкрементировался на 1.
Если что-то не пришло или пришло без маркера — debug: проверить `error_log` на сервере, проверить, что новая версия плагина действительно загружена (`wp plugin list --status=active`), проверить, что Akismet активен и помечает заявку как spam.
- [ ] **Step 14.7: Merge в main и cleanup**
```bash
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/cf7-spam-to-telegram"
vendor/bin/phpunit # последняя регрессия перед merge
git checkout main
git merge --no-ff feat/silent-drop -m "Merge feat/silent-drop: silent drop label + dry-run + counters (1.1.0)"
git branch -d feat/silent-drop
```
- [ ] **Step 14.8: Закрыть beads-задачи**
```bash
# Закрыть все task-id, потом эпик
bd close <task-id> --reason "Done — merged to main, deployed to домработница.рус"
bd close <epic-id> --reason "Released 1.1.0"
```
---
## Self-Review
**Spec coverage:**
- Метка `drop` + порог + hard-rule → Tasks 3-6 ✓
- `valid_email_anywhere` (любой валидный email защищает) → Task 3 ✓
- `short_latin_name` (только при наличии name-поля) → Task 4 ✓
- `count_placeholder_fields` (только непустые/неслужебные) → Task 5 ✓
- `evaluate_hard_drop` интеграция + score-порог → Task 6 ✓
- Settings (опции, счётчик, rolling даты, reset) → Task 7 ✓
- Activator (safe defaults + add_option) → Task 8 ✓
- CF7Source (drop → skip / dry-run mark, filter `cf7stg_should_enqueue`) → Task 9 ✓
- MessageFormatter (`[БЫЛО БЫ DROPPED]` + строка триггера) → Task 10 ✓
- Settings page UI → Task 11 ✓
- Dashboard widget → Task 12 ✓
- Bump 1.1.0 + changelog → Task 13 ✓
- Smoke test + merge → Task 14 ✓
- Filter `cf7stg_hard_drop_rules` → реализован в Task 6 ✓
- Filter `cf7stg_classifier_thresholds` → переиспользуется (уже есть) ✓
**Type consistency:**
- `Settings::increment_drop_counter($mode)` принимает `'real' | 'dry_run'` — везде используется именно эти строки.
- `decide_drop_action` возвращает `action: 'enqueue' | 'enqueue_marked' | 'skip'``MessageFormatter` смотрит на `payload['dry_run_drop']` (boolean), это согласовано.
- Опции (`OPT_DRY_RUN_DROP`, `OPT_DROP_THRESHOLD`, `OPT_HARD_DROP_ENABLED`, `OPT_DROP_COUNTERS`) — одинаковые имена в Settings, Activator и Settings page.
**Placeholder scan:** TBD/TODO/«handle edge cases» — нет.
**Scope check:** одна фича (silent drop), один plan.