Files
cf7-spam-to-telegram/docs/superpowers/plans/2026-04-21-cf7stg-silent-drop.md
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

67 KiB
Raw Permalink Blame History

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 где есть жёсткая последовательность.

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
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: Закоммитить
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
// Реальная заявка из 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
// Реальная заявка из 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
// Реальная заявка из 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: Закоммитить
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
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):

    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: Закоммитить
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

    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):

    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: Закоммитить
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: Добавить тесты

    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:

    /** 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: Закоммитить
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
    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 заменить:

    public const DEFAULT_THRESHOLDS = [
        'client' => 3,
        'spam'   => -2,
        'drop'   => -5,
    ];
  • Step 6.4: Добавить evaluate_hard_drop()

Добавить в Classifier:

    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):

        $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: Закоммитить
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
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

В начало класса добавить новые константы:

    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';

В конец класса добавить методы:

    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: Закоммитить
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():

    public static function activate(): void
    {
        self::guard_requirements();
        self::install_table();
        self::seed_silent_drop_options();
        self::schedule_events();
    }

И добавить новый метод:

    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: Закоммитить
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
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:

    /**
     * @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 = ...):

        $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: Закоммитить
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
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):

Добавить новую константу:

    private const LABEL_MAP = [
        'client'  => ['emoji' => '🟢', 'text' => 'похоже клиент'],
        'unclear' => ['emoji' => '🟡', 'text' => 'неясно'],
        'spam'    => ['emoji' => '🔴', 'text' => 'похоже спам'],
        'drop'    => ['emoji' => '🟡', 'text' => 'неясно'],
    ];

(label drop в обычном режиме сюда не попадает — отбрасывается до MessageFormatter. Но в dry-run попадает; используем нейтральный жёлтый, потому что заголовок будет переопределён.)

Заменить блок построения header:

        $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):

        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: Закоммитить
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', ...):

        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):

    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'])) добавить:

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) вставить:

    <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: Закоммитить
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 = ...):

        $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 /** @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-аннотациям добавить:

/** @var bool $dry_run_on */
/** @var array $drop_counters */
  • Step 12.3: Регрессия

Run: vendor/bin/phpunit Expected: всё зелёное.

  • Step 12.4: Закоммитить
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 заменить:

 * Version: 1.1.0

и

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: Закоммитить
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/. Например:

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 на сервере
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
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-задачи
# Закрыть все 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.