From 9a554049185ea4ad76948d528b49a42086038f29 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Tue, 21 Apr 2026 15:37:42 +0500 Subject: [PATCH 01/21] =?UTF-8?q?docs:=20implementation=20plan=20for=20sil?= =?UTF-8?q?ent=20drop=20(1.1.0)=20=E2=80=94=2014=20tasks,=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-21-cf7stg-silent-drop.md | 1773 +++++++++++++++++ 1 file changed, 1773 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-21-cf7stg-silent-drop.md diff --git a/docs/superpowers/plans/2026-04-21-cf7stg-silent-drop.md b/docs/superpowers/plans/2026-04-21-cf7stg-silent-drop.md new file mode 100644 index 0000000..25e5608 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-cf7stg-silent-drop.md @@ -0,0 +1,1773 @@ +# 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 --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 "<краткое описание задачи>" "" +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. From 40d5a2c23dc480cd4ad6821cad82b99c52ab58f2 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 15:48:07 +0500 Subject: [PATCH 02/21] test: stateful WP shims (add_filter/get_option/is_email) for upcoming silent-drop tests --- tests/bootstrap.php | 121 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 7 deletions(-) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6c517e0..5a90ffa 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,15 +1,12 @@ <?php declare(strict_types=1); -// Simple autoloader that mirrors the runtime plugin autoloader, -// mapping Cf7stg\ClassName → includes/class-class-name.php +// Autoloader (без изменений) spl_autoload_register(static function (string $class): void { if (strpos($class, 'Cf7stg\\') !== 0) { return; } $relative = substr($class, strlen('Cf7stg\\')); - // Turn "PhoneParser" into "phone-parser"; also normalize underscores - // ("Settings_Page" → "settings-page"). $slug = strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1-$2', $relative)); $slug = str_replace('_', '-', $slug); $path = __DIR__ . '/../includes/class-' . $slug . '.php'; @@ -18,10 +15,117 @@ spl_autoload_register(static function (string $class): void { } }); -// Minimal shims for functions used by pure-logic classes when WP is absent. -if (!function_exists('apply_filters')) { - function apply_filters($tag, $value) { return $value; } +// 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'); } } @@ -34,3 +138,6 @@ if (!function_exists('wp_strip_all_tags')) { 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); } +} From bd45e8463469624f8dc48a8a9abe7b5eee6dfd69 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 15:51:46 +0500 Subject: [PATCH 03/21] test: stable filter ordering, base TestCase reset, priority-aware remove_all_filters Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- tests/Cf7stgTestCase.php | 15 +++++++++++++++ tests/bootstrap.php | 23 +++++++++++++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 tests/Cf7stgTestCase.php diff --git a/tests/Cf7stgTestCase.php b/tests/Cf7stgTestCase.php new file mode 100644 index 0000000..e668d3f --- /dev/null +++ b/tests/Cf7stgTestCase.php @@ -0,0 +1,15 @@ +<?php +declare(strict_types=1); + +namespace Cf7stg\Tests; + +use PHPUnit\Framework\TestCase; + +abstract class Cf7stgTestCase extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + \Cf7stgWpShimState::reset(); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 5a90ffa..198ab9d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -20,13 +20,15 @@ final class Cf7stgWpShimState { /** @var array<string,mixed> */ public static array $options = []; - /** @var array<string,array<int,array{cb:callable,priority:int,accepted_args:int}>> */ + /** @var array<string,array<int,array{cb:callable,priority:int,accepted_args:int,seq:int}>> */ public static array $filters = []; + public static int $next_seq = 0; public static function reset(): void { self::$options = []; self::$filters = []; + self::$next_seq = 0; } } @@ -36,7 +38,7 @@ if (!function_exists('apply_filters')) { return $value; } $callbacks = Cf7stgWpShimState::$filters[$tag]; - usort($callbacks, static fn($a, $b) => $a['priority'] <=> $b['priority']); + usort($callbacks, static fn($a, $b) => ($a['priority'] <=> $b['priority']) ?: ($a['seq'] <=> $b['seq'])); 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); @@ -48,7 +50,10 @@ if (!function_exists('apply_filters')) { 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, + 'cb' => $cb, + 'priority' => (int)$priority, + 'accepted_args' => (int)$accepted_args, + 'seq' => Cf7stgWpShimState::$next_seq++, ]; return true; } @@ -56,7 +61,17 @@ if (!function_exists('add_filter')) { if (!function_exists('remove_all_filters')) { function remove_all_filters($tag, $priority = false) { - unset(Cf7stgWpShimState::$filters[$tag]); + if ($priority === false) { + unset(Cf7stgWpShimState::$filters[$tag]); + return true; + } + if (!isset(Cf7stgWpShimState::$filters[$tag])) { + return true; + } + Cf7stgWpShimState::$filters[$tag] = array_values(array_filter( + Cf7stgWpShimState::$filters[$tag], + static fn($f) => $f['priority'] !== (int)$priority + )); return true; } } From 1d2586e290f5bb9a012d7d0d257b94569b982b8b Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 15:53:54 +0500 Subject: [PATCH 04/21] test(fixtures): real spam submissions from TG screenshots (3 cases) --- tests/fixtures/spam-screenshot-1.php | 11 +++++++++++ tests/fixtures/spam-screenshot-2.php | 11 +++++++++++ tests/fixtures/spam-screenshot-3.php | 9 +++++++++ 3 files changed, 31 insertions(+) create mode 100644 tests/fixtures/spam-screenshot-1.php create mode 100644 tests/fixtures/spam-screenshot-2.php create mode 100644 tests/fixtures/spam-screenshot-3.php diff --git a/tests/fixtures/spam-screenshot-1.php b/tests/fixtures/spam-screenshot-1.php new file mode 100644 index 0000000..8e03556 --- /dev/null +++ b/tests/fixtures/spam-screenshot-1.php @@ -0,0 +1,11 @@ +<?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', +]; diff --git a/tests/fixtures/spam-screenshot-2.php b/tests/fixtures/spam-screenshot-2.php new file mode 100644 index 0000000..b998345 --- /dev/null +++ b/tests/fixtures/spam-screenshot-2.php @@ -0,0 +1,11 @@ +<?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', +]; diff --git a/tests/fixtures/spam-screenshot-3.php b/tests/fixtures/spam-screenshot-3.php new file mode 100644 index 0000000..898ca6f --- /dev/null +++ b/tests/fixtures/spam-screenshot-3.php @@ -0,0 +1,9 @@ +<?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?', +]; From eae8fafb326f8fd2f24ca5a9cbba8811b03a643a Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 15:56:14 +0500 Subject: [PATCH 05/21] feat(classifier): has_valid_email_anywhere() helper for drop-logic --- includes/class-classifier.php | 15 +++++++++++++++ tests/ClassifierDropTest.php | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/ClassifierDropTest.php diff --git a/includes/class-classifier.php b/includes/class-classifier.php index a6f7986..bcbccc1 100644 --- a/includes/class-classifier.php +++ b/includes/class-classifier.php @@ -215,4 +215,19 @@ final class Classifier } return $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; + } } diff --git a/tests/ClassifierDropTest.php b/tests/ClassifierDropTest.php new file mode 100644 index 0000000..7c283cf --- /dev/null +++ b/tests/ClassifierDropTest.php @@ -0,0 +1,32 @@ +<?php +declare(strict_types=1); + +namespace Cf7stg\Tests; + +use Cf7stg\Classifier; + +final class ClassifierDropTest extends Cf7stgTestCase +{ + 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([])); + } +} From b5c939e9e48cc80ebbdeab13fd7e5943944bc52d Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 15:59:12 +0500 Subject: [PATCH 06/21] fix(classifier): iterate all email candidates per field; document method contract Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- includes/class-classifier.php | 17 +++++++++++++++-- tests/ClassifierDropTest.php | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/includes/class-classifier.php b/includes/class-classifier.php index bcbccc1..9686d32 100644 --- a/includes/class-classifier.php +++ b/includes/class-classifier.php @@ -216,6 +216,17 @@ final class Classifier return $hits; } + /** + * Checks whether any field value contains a syntactically valid email address. + * + * Used by drop-logic to spare submissions that include any plausible contact email + * (any TLD, including gmail.com and custom domains — not limited to RU domains). + * + * Handles flat string values and one level of array nesting (matches CF7 posted_data + * shape). Deeper nesting is intentionally NOT supported. + * + * @param array<string,string|string[]> $fields CF7 posted_data + */ public static function has_valid_email_anywhere(array $fields): bool { foreach ($fields as $v) { @@ -223,8 +234,10 @@ final class Classifier 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; + if (preg_match_all('/[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/', $val, $matches)) { + foreach ($matches[0] as $candidate) { + if (is_email($candidate)) return true; + } } } } diff --git a/tests/ClassifierDropTest.php b/tests/ClassifierDropTest.php index 7c283cf..067c510 100644 --- a/tests/ClassifierDropTest.php +++ b/tests/ClassifierDropTest.php @@ -29,4 +29,18 @@ final class ClassifierDropTest extends Cf7stgTestCase { self::assertFalse(Classifier::has_valid_email_anywhere([])); } + + public function test_has_valid_email_anywhere_finds_email_embedded_in_text(): void + { + $fields = ['your-message' => 'reach me at user@domain.com thanks']; + self::assertTrue(Classifier::has_valid_email_anywhere($fields)); + } + + public function test_has_valid_email_anywhere_finds_second_email_when_first_invalid(): void + { + // First candidate is "alice@in" which is not a valid email; second is valid. + // Without preg_match_all, the method would return false here. + $fields = ['msg' => 'try alice@in or bob@example.com']; + self::assertTrue(Classifier::has_valid_email_anywhere($fields)); + } } From 660a0d2b7c478aebfda8aba847fd92bbd1bf7dda Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 16:01:58 +0500 Subject: [PATCH 07/21] feat(classifier): has_short_latin_name() helper for drop-logic --- includes/class-classifier.php | 19 ++++++++++++++++++ tests/ClassifierDropTest.php | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/includes/class-classifier.php b/includes/class-classifier.php index 9686d32..4eaaf13 100644 --- a/includes/class-classifier.php +++ b/includes/class-classifier.php @@ -243,4 +243,23 @@ final class Classifier } return false; } + + /** + * Checks whether the form's name field contains 1-3 ASCII alphanumeric characters + * (typical bot pattern: "OB", "MU", "A1B"). Used by drop hard-rule. + * + * Looks first at canonical CF7 keys (your-name, name, имя, fio), then falls back to + * the value-based heuristic in FieldDetector::find_name(). Returns false if no name + * field is present (i.e. forms without a name field are immune to this rule). + * + * @param array<string,string|string[]> $fields CF7 posted_data + */ + 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); + } } diff --git a/tests/ClassifierDropTest.php b/tests/ClassifierDropTest.php index 067c510..6b91278 100644 --- a/tests/ClassifierDropTest.php +++ b/tests/ClassifierDropTest.php @@ -43,4 +43,42 @@ final class ClassifierDropTest extends Cf7stgTestCase $fields = ['msg' => 'try alice@in or bob@example.com']; self::assertTrue(Classifier::has_valid_email_anywhere($fields)); } + + 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::find_name пропустит +79991234567 (digits) + $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 найдёт 'XY' (no digits, len=2) + $fields = ['text-211' => 'XY', 'text-212' => '+79991234567']; + self::assertTrue(Classifier::has_short_latin_name($fields)); + } } From 1168a733a40b9e025c457815a85ca02d8a4cbee5 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 16:04:18 +0500 Subject: [PATCH 08/21] test(classifier): boundary + array-value tests for has_short_latin_name; @return tags - Add @return bool to has_valid_email_anywhere docblock - Add @return bool to has_short_latin_name docblock - Add Cyrillic note to has_short_latin_name docblock - Add test_has_short_latin_name_true_for_single_letter_boundary (lower bound) - Add test_has_short_latin_name_false_for_four_letters_boundary (upper bound) - Add test_has_short_latin_name_false_for_array_value_with_space_join (regression) Addresses code review feedback from commit 660a0d2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- includes/class-classifier.php | 4 ++++ tests/ClassifierDropTest.php | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/includes/class-classifier.php b/includes/class-classifier.php index 4eaaf13..63c6860 100644 --- a/includes/class-classifier.php +++ b/includes/class-classifier.php @@ -226,6 +226,7 @@ final class Classifier * shape). Deeper nesting is intentionally NOT supported. * * @param array<string,string|string[]> $fields CF7 posted_data + * @return bool True if any value contains a syntactically valid email */ public static function has_valid_email_anywhere(array $fields): bool { @@ -252,7 +253,10 @@ final class Classifier * the value-based heuristic in FieldDetector::find_name(). Returns false if no name * field is present (i.e. forms without a name field are immune to this rule). * + * Cyrillic look-alikes (e.g. 'ОВ') are rejected because the regex is byte-range. + * * @param array<string,string|string[]> $fields CF7 posted_data + * @return bool True if a name field exists and is 1-3 ASCII alphanumeric chars */ public static function has_short_latin_name(array $fields): bool { diff --git a/tests/ClassifierDropTest.php b/tests/ClassifierDropTest.php index 6b91278..ff77f92 100644 --- a/tests/ClassifierDropTest.php +++ b/tests/ClassifierDropTest.php @@ -81,4 +81,25 @@ final class ClassifierDropTest extends Cf7stgTestCase $fields = ['text-211' => 'XY', 'text-212' => '+79991234567']; self::assertTrue(Classifier::has_short_latin_name($fields)); } + + public function test_has_short_latin_name_true_for_single_letter_boundary(): void + { + // Lower boundary of {1,3}: 1 char accepted + $fields = ['your-name' => 'A']; + self::assertTrue(Classifier::has_short_latin_name($fields)); + } + + public function test_has_short_latin_name_false_for_four_letters_boundary(): void + { + // Upper boundary of {1,3}: 4 chars rejected + $fields = ['your-name' => 'ABCD']; + self::assertFalse(Classifier::has_short_latin_name($fields)); + } + + public function test_has_short_latin_name_false_for_array_value_with_space_join(): void + { + // pick_field joins array values with a space → "OB MU" is 5 chars → no false positive. + $fields = ['your-name' => ['OB', 'MU']]; + self::assertFalse(Classifier::has_short_latin_name($fields)); + } } From 249f1952576324331e64aa1b750eeb114bd8ebbf Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 16:06:32 +0500 Subject: [PATCH 09/21] feat(classifier): count_placeholder_fields() helper for drop-logic Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- includes/class-classifier.php | 36 ++++++++++++++++++++++++++++++++ tests/ClassifierDropTest.php | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/includes/class-classifier.php b/includes/class-classifier.php index 63c6860..84a095c 100644 --- a/includes/class-classifier.php +++ b/includes/class-classifier.php @@ -46,6 +46,14 @@ final class Classifier 'rambler.ru', 'gmail.com', ]; + /** Service field keys 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', + ]; + /** * @param array<string,mixed> $fields CF7 posted_data (field => value or array of values) * @param array{reason?:string} $meta Context: reason = Akismet / Honeypot / CF7 rules / Неизвестно @@ -266,4 +274,32 @@ final class Classifier if ($name === '') return false; return (bool)preg_match('/^[A-Za-z0-9]{1,3}$/', $name); } + + /** + * Counts non-empty fields whose value is exactly 1-2 ASCII alphanumeric chars + * (the typical "AB", "70" placeholder pattern bots fill custom-keyed fields with). + * + * Skips service keys (acceptance-data, _wpcf7*, g-recaptcha-response) and any key + * starting with `_wpcf7`. Array values are joined without separators before length + * check (so `['A','B']` collapses to "AB" — still counted). + * + * @param array<string,string|string[]|null> $fields CF7 posted_data + * @return int Number of fields matching the placeholder pattern + */ + 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; + } } diff --git a/tests/ClassifierDropTest.php b/tests/ClassifierDropTest.php index ff77f92..ec21dd7 100644 --- a/tests/ClassifierDropTest.php +++ b/tests/ClassifierDropTest.php @@ -102,4 +102,43 @@ final class ClassifierDropTest extends Cf7stgTestCase $fields = ['your-name' => ['OB', 'MU']]; self::assertFalse(Classifier::has_short_latin_name($fields)); } + + 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)); + } } From 18a07c9b1b80410a47a67f0ada377a9681d99447 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 16:08:57 +0500 Subject: [PATCH 10/21] test(classifier): exact-count assertions for placeholder fixture; document SERVICE_FIELD_KEYS prefix semantics Fixes issues from code review in commit 249f195: 1. Tighten fixture assertions to exact counts (assertSame instead of assertGreaterThanOrEqual): - test_count_placeholder_fields_screenshot_1_exact_count: expect 5 placeholders - test_count_placeholder_fields_screenshot_2_exact_count: expect 5 placeholders - test_count_placeholder_fields_screenshot_3_exact_count: expect 1 placeholder 2. Document SERVICE_FIELD_KEYS prefix-matching semantics in comment: clarify that keys starting with _wpcf7 are skipped via strpos prefix match, so explicit enumeration of every _wpcf7_* variant is unnecessary. Test results: 75/75 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- includes/class-classifier.php | 7 ++++++- tests/ClassifierDropTest.php | 21 ++++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/includes/class-classifier.php b/includes/class-classifier.php index 84a095c..6874382 100644 --- a/includes/class-classifier.php +++ b/includes/class-classifier.php @@ -46,7 +46,12 @@ final class Classifier 'rambler.ru', 'gmail.com', ]; - /** Service field keys excluded from placeholder count. */ + /** + * Explicit service field keys excluded from placeholder count. + * Note: keys starting with `_wpcf7` are also skipped via prefix match + * (see count_placeholder_fields), so this list does not need to enumerate + * every `_wpcf7_*` variant — only non-prefixed service keys. + */ private const SERVICE_FIELD_KEYS = [ 'acceptance-data', '_wpcf7', '_wpcf7_version', '_wpcf7_locale', '_wpcf7_unit_tag', diff --git a/tests/ClassifierDropTest.php b/tests/ClassifierDropTest.php index ec21dd7..f8ed6f3 100644 --- a/tests/ClassifierDropTest.php +++ b/tests/ClassifierDropTest.php @@ -103,12 +103,27 @@ final class ClassifierDropTest extends Cf7stgTestCase self::assertFalse(Classifier::has_short_latin_name($fields)); } - public function test_count_placeholder_fields_screenshot_1(): void + public function test_count_placeholder_fields_screenshot_1_exact_count(): 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-785: OB(2), text-787: 70(2), text-786: BP(2), text-788: ZZ(2), text-789: QV(2) → 5 placeholders. // text-790: 10 цифр — не placeholder. acceptance-data: служебное → исключено. - self::assertGreaterThanOrEqual(3, Classifier::count_placeholder_fields($fields)); + self::assertSame(5, Classifier::count_placeholder_fields($fields)); + } + + public function test_count_placeholder_fields_screenshot_2_exact_count(): void + { + $fields = require __DIR__ . '/fixtures/spam-screenshot-2.php'; + // Same shape as screenshot-1: 5 placeholder fields. + self::assertSame(5, Classifier::count_placeholder_fields($fields)); + } + + public function test_count_placeholder_fields_screenshot_3_exact_count(): void + { + $fields = require __DIR__ . '/fixtures/spam-screenshot-3.php'; + // text-347: MU(2) is the only placeholder; tel-185(10), text-subject (long), text-title (long) + // are not. acceptance-data: служебное. + self::assertSame(1, Classifier::count_placeholder_fields($fields)); } public function test_count_placeholder_fields_ignores_empty(): void From a70785beca295c79750a7bd95474aa7fdce74d20 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 16:14:49 +0500 Subject: [PATCH 11/21] feat(classifier): label 'drop' via hard-rule + score threshold (-5 default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DEFAULT_THRESHOLDS['drop'] = -5 and DEFAULT_HARD_DROP_RULES const - Add evaluate_hard_drop(): fires when no positive signals + short latin name OR ≥3 placeholder fields - Wire into classify(): drop overrides all other labels; synthetic reason explains trigger - Add drop_reason_label() and 'drop' => '⛔ авто-дроп' to MessageFormatter::LABEL_MAP - Update ClassifierTest/MessageFormatterTest to extend Cf7stgTestCase (filter isolation) - Adjust pre-existing assertions that now correctly yield 'drop' instead of 'spam' --- includes/class-classifier.php | 56 ++++++++++++ includes/class-message-formatter.php | 1 + tests/ClassifierDropTest.php | 127 +++++++++++++++++++++++++++ tests/ClassifierTest.php | 9 +- tests/MessageFormatterTest.php | 27 +++++- 5 files changed, 214 insertions(+), 6 deletions(-) diff --git a/includes/class-classifier.php b/includes/class-classifier.php index 6874382..e1a667d 100644 --- a/includes/class-classifier.php +++ b/includes/class-classifier.php @@ -20,6 +20,12 @@ final class Classifier public const DEFAULT_THRESHOLDS = [ 'client' => 3, 'spam' => -2, + 'drop' => -5, + ]; + + public const DEFAULT_HARD_DROP_RULES = [ + 'short_latin_name' => true, + 'placeholder_fields' => true, ]; public const DEFAULT_KEYWORDS = [ @@ -153,9 +159,28 @@ final class Classifier $label = 'spam'; } + // Drop has highest priority (overrides client/unclear/spam/honeypot) + $is_drop_by_rule = self::evaluate_hard_drop($fields); + $is_drop_by_score = isset($thresholds['drop']) && $score <= (int)$thresholds['drop']; + if ($is_drop_by_rule || $is_drop_by_score) { + $label = 'drop'; + $reasons[] = ['sign' => '×', 'weight' => 0, 'text' => self::drop_reason_label($fields, $thresholds, $is_drop_by_rule)]; + } + return ['score' => $score, 'label' => $label, 'reasons' => $reasons]; } + private static function drop_reason_label(array $fields, array $thresholds, bool $by_rule): string + { + if ($by_rule) { + $hits = []; + if (self::has_short_latin_name($fields)) $hits[] = 'короткое латинское имя'; + if (self::count_placeholder_fields($fields) >= 3) $hits[] = '≥3 поля-плейсхолдера'; + return 'hard-rule: ' . implode(' + ', $hits); + } + return 'score ≤ ' . (int)$thresholds['drop']; + } + private static function concat_fields(array $fields): string { $out = []; @@ -307,4 +332,35 @@ final class Classifier } return $count; } + + /** + * Hard-rule decision: returns true if the submission has zero positive signals + * (no cyrillic name, no meaningful text, no valid email anywhere) AND at least + * one trigger fires (short latin name OR ≥3 placeholder fields). + * + * Triggers can be disabled individually via filter `cf7stg_hard_drop_rules`. + * + * @param array<string,string|string[]> $fields CF7 posted_data + * @return bool True if the submission should be silently dropped by hard-rule. + */ + 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; + } } diff --git a/includes/class-message-formatter.php b/includes/class-message-formatter.php index 17bf409..b22f5e8 100644 --- a/includes/class-message-formatter.php +++ b/includes/class-message-formatter.php @@ -11,6 +11,7 @@ final class MessageFormatter 'client' => ['emoji' => '🟢', 'text' => 'похоже клиент'], 'unclear' => ['emoji' => '🟡', 'text' => 'неясно'], 'spam' => ['emoji' => '🔴', 'text' => 'похоже спам'], + 'drop' => ['emoji' => '⛔', 'text' => 'авто-дроп'], ]; /** diff --git a/tests/ClassifierDropTest.php b/tests/ClassifierDropTest.php index f8ed6f3..907dfef 100644 --- a/tests/ClassifierDropTest.php +++ b/tests/ClassifierDropTest.php @@ -156,4 +156,131 @@ final class ClassifierDropTest extends Cf7stgTestCase $fields = ['a' => 'Ия', 'b' => 'a b', 'c' => 'XY']; self::assertSame(1, Classifier::count_placeholder_fields($fields)); } + + 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'], + 'doménный' => ['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 + // Имя длинное (Melissa Smith), сообщение latin_only → НЕ hard-rule, чисто по score. + $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; + }); + // URL даёт -3, нет hard-rule (нет короткого латинского имени, нет 3+ placeholder), + // но -3 ≤ -3 → drop по score-порогу. + $fields = ['msg' => 'http://x.com']; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertSame('drop', $r['label']); + } } diff --git a/tests/ClassifierTest.php b/tests/ClassifierTest.php index 17ac355..4395f25 100644 --- a/tests/ClassifierTest.php +++ b/tests/ClassifierTest.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace Cf7stg\Tests; use Cf7stg\Classifier; -use PHPUnit\Framework\TestCase; -final class ClassifierTest extends TestCase +final class ClassifierTest extends Cf7stgTestCase { public function test_real_client_inquiry_is_green(): void { @@ -23,14 +22,16 @@ final class ClassifierTest extends TestCase public function test_seo_spam_with_url_is_red(): void { + // score = SEO(-2) + URL(-3) + latin_only(-1) = -6, which is ≤ drop threshold(-5) + // → label promoted from spam to drop (score-based drop, no hard-rule since email is valid) $fields = [ 'your-name' => 'Melissa Johnson', 'your-email' => 'promo@example.com', 'your-message' => 'Hi, we boost SEO positions, visit http://cheap-seo.net', ]; $r = Classifier::classify($fields, ['reason' => 'Akismet']); - self::assertSame('spam', $r['label']); - self::assertLessThanOrEqual(-2, $r['score']); + self::assertSame('drop', $r['label']); + self::assertLessThanOrEqual(-5, $r['score']); } public function test_honeypot_forces_spam_even_with_phone(): void diff --git a/tests/MessageFormatterTest.php b/tests/MessageFormatterTest.php index 4c284c2..a2226ed 100644 --- a/tests/MessageFormatterTest.php +++ b/tests/MessageFormatterTest.php @@ -5,9 +5,8 @@ namespace Cf7stg\Tests; use Cf7stg\MessageFormatter; use Cf7stg\Classifier; -use PHPUnit\Framework\TestCase; -final class MessageFormatterTest extends TestCase +final class MessageFormatterTest extends Cf7stgTestCase { public function test_full_client_payload(): void { @@ -123,6 +122,8 @@ final class MessageFormatterTest extends TestCase public function test_spam_label_emoji(): void { + // score = SEO(-2) + URL(-3) + latin_only(-1) = -6 ≤ drop threshold(-5) → label=drop + // Fields with no valid email and name 'Melissa' (7 chars, not short latin) → score-based drop. $fields = [ 'your-name' => 'Melissa', 'your-message' => 'SEO promotion, visit http://spam.net', @@ -135,6 +136,28 @@ final class MessageFormatterTest extends TestCase 'ip' => '', 'user_agent' => '', 'is_retro' => false, ]); + self::assertStringContainsString('⛔ авто-дроп', $r['text']); + } + + public function test_spam_label_emoji_pure_spam(): void + { + // Score -3 (URL) + -2 (SEO keyword) = -5, but no drop because cyrillic name protects + // from hard-rule, and score -5 is NOT ≤ drop threshold -5 (it equals it, so it IS drop). + // Use URL(-3) only → score=-3, between spam(-2) and drop(-5) → spam label. + $fields = [ + 'your-name' => 'Nikolay', + 'your-email' => 'nick@example.com', + 'your-message' => 'visit http://promo.net for deals', + ]; + $classification = Classifier::classify($fields, ['reason' => 'Akismet']); + // valid email prevents hard-rule; score = URL(-3) + latin_only(-1) = -4 → spam + $r = MessageFormatter::build([ + 'fields' => $fields, 'classification' => $classification, + 'site_title' => 's.ru', 'form_title' => 'f', + 'submitted_at' => '17.04.2026', 'reason' => 'Akismet', + 'ip' => '', 'user_agent' => '', 'is_retro' => false, + ]); + self::assertStringContainsString('🔴 похоже спам', $r['text']); } From f415f429ed744f7012e7e20ceef8a18bddbc6b0b Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 16:20:28 +0500 Subject: [PATCH 12/21] refactor(classifier): single-pass hard-drop trigger; cleaner drop reason rendering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- includes/class-classifier.php | 70 ++++++++++++++++------------ includes/class-message-formatter.php | 6 +++ tests/ClassifierTest.php | 2 +- 3 files changed, 46 insertions(+), 32 deletions(-) diff --git a/includes/class-classifier.php b/includes/class-classifier.php index e1a667d..56e6848 100644 --- a/includes/class-classifier.php +++ b/includes/class-classifier.php @@ -160,27 +160,17 @@ final class Classifier } // Drop has highest priority (overrides client/unclear/spam/honeypot) - $is_drop_by_rule = self::evaluate_hard_drop($fields); + $drop_trigger = self::compute_hard_drop_trigger($fields); $is_drop_by_score = isset($thresholds['drop']) && $score <= (int)$thresholds['drop']; - if ($is_drop_by_rule || $is_drop_by_score) { + if ($drop_trigger !== null || $is_drop_by_score) { $label = 'drop'; - $reasons[] = ['sign' => '×', 'weight' => 0, 'text' => self::drop_reason_label($fields, $thresholds, $is_drop_by_rule)]; + $reason_text = $drop_trigger ?? ('score ≤ ' . (int)$thresholds['drop']); + $reasons[] = ['sign' => '×', 'weight' => 0, 'text' => $reason_text]; } return ['score' => $score, 'label' => $label, 'reasons' => $reasons]; } - private static function drop_reason_label(array $fields, array $thresholds, bool $by_rule): string - { - if ($by_rule) { - $hits = []; - if (self::has_short_latin_name($fields)) $hits[] = 'короткое латинское имя'; - if (self::count_placeholder_fields($fields) >= 3) $hits[] = '≥3 поля-плейсхолдера'; - return 'hard-rule: ' . implode(' + ', $hits); - } - return 'score ≤ ' . (int)$thresholds['drop']; - } - private static function concat_fields(array $fields): string { $out = []; @@ -333,6 +323,40 @@ final class Classifier return $count; } + /** + * Returns the hard-rule trigger description, or null if no rule fires. + * Centralises positive-signal checks + trigger detection so callers don't + * recompute. Used by evaluate_hard_drop() and classify()'s reason builder. + * + * @param array<string,string|string[]> $fields CF7 posted_data + * @return string|null e.g. "hard-rule: короткое латинское имя + ≥3 поля-плейсхолдера" + */ + private static function compute_hard_drop_trigger(array $fields): ?string + { + $rules = apply_filters('cf7stg_hard_drop_rules', self::DEFAULT_HARD_DROP_RULES); + + // Positive-signal short-circuits + $name = self::pick_field($fields, ['your-name', 'name', 'имя', 'fio']); + if ($name === '') $name = FieldDetector::find_name($fields); + if ($name !== '' && self::is_cyrillic_name($name)) return null; + + $message = self::pick_field($fields, ['your-message', 'message', 'comment', 'сообщение', 'вопрос']); + if ($message !== '' && self::is_meaningful_text($message)) return null; + + if (self::has_valid_email_anywhere($fields)) return null; + + // Triggers + $hits = []; + if (!empty($rules['short_latin_name']) && self::has_short_latin_name($fields)) { + $hits[] = 'короткое латинское имя'; + } + if (!empty($rules['placeholder_fields']) && self::count_placeholder_fields($fields) >= 3) { + $hits[] = '≥3 поля-плейсхолдера'; + } + if (empty($hits)) return null; + return 'hard-rule: ' . implode(' + ', $hits); + } + /** * Hard-rule decision: returns true if the submission has zero positive signals * (no cyrillic name, no meaningful text, no valid email anywhere) AND at least @@ -345,22 +369,6 @@ final class Classifier */ 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; + return self::compute_hard_drop_trigger($fields) !== null; } } diff --git a/includes/class-message-formatter.php b/includes/class-message-formatter.php index b22f5e8..f052bf8 100644 --- a/includes/class-message-formatter.php +++ b/includes/class-message-formatter.php @@ -108,6 +108,12 @@ final class MessageFormatter if (!$reasons) return '—'; $parts = []; foreach ($reasons as $r) { + // Drop reasons use sign='×' weight=0 as a sentinel — render text only + // to avoid the meaningless "×0 hard-rule: ..." prefix. + if (($r['sign'] ?? '') === '×' && (int)($r['weight'] ?? 0) === 0) { + $parts[] = (string)$r['text']; + continue; + } $parts[] = $r['sign'] . $r['weight'] . ' ' . $r['text']; } return esc_html(implode(', ', $parts)); diff --git a/tests/ClassifierTest.php b/tests/ClassifierTest.php index 4395f25..dcad7a9 100644 --- a/tests/ClassifierTest.php +++ b/tests/ClassifierTest.php @@ -20,7 +20,7 @@ final class ClassifierTest extends Cf7stgTestCase self::assertGreaterThanOrEqual(3, $r['score']); } - public function test_seo_spam_with_url_is_red(): void + public function test_seo_spam_with_url_yields_drop(): void { // score = SEO(-2) + URL(-3) + latin_only(-1) = -6, which is ≤ drop threshold(-5) // → label promoted from spam to drop (score-based drop, no hard-rule since email is valid) From ae70b42dcba36c401c4f1d70ded05c755c8727a1 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 16:22:57 +0500 Subject: [PATCH 13/21] =?UTF-8?q?feat(settings):=20silent-drop=20options?= =?UTF-8?q?=20+=20counter=20with=20rolling=20today=E2=86=92yesterday?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- includes/class-settings.php | 92 +++++++++++++++++++++++++++++ tests/SettingsCounterTest.php | 105 ++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 tests/SettingsCounterTest.php diff --git a/includes/class-settings.php b/includes/class-settings.php index 6a340dc..dd3952b 100644 --- a/includes/class-settings.php +++ b/includes/class-settings.php @@ -10,6 +10,11 @@ final class Settings public const OPT_SITE_TITLE = 'cf7stg_site_title'; public const OPT_LAST_TEST = 'cf7stg_last_test'; + 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 get_bot_token(): string { if (defined('SPAM2TG_BOT_TOKEN') && SPAM2TG_BOT_TOKEN !== '') { @@ -76,4 +81,91 @@ final class Settings { return self::get_bot_token() !== '' && self::get_chat_id() !== ''; } + + 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); + } + + /** + * @return array{total:int,real_today:int,real_yesterday:int,dry_today:int,dry_yesterday:int,today_date:string,reset_at:string} + */ + 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); + } + + /** + * Atomically increments the drop counter. `$mode` must be 'real' or 'dry_run'; + * any other value is a no-op (defensive — guards against typos in callers). + * Performs rolling today→yesterday on date change. + */ + 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, + ]; + } } diff --git a/tests/SettingsCounterTest.php b/tests/SettingsCounterTest.php new file mode 100644 index 0000000..eb195d0 --- /dev/null +++ b/tests/SettingsCounterTest.php @@ -0,0 +1,105 @@ +<?php +declare(strict_types=1); + +namespace Cf7stg\Tests; + +use Cf7stg\Settings; + +final class SettingsCounterTest extends Cf7stgTestCase +{ + 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 + { + $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 + { + 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); + } + + public function test_increment_with_invalid_mode_is_noop(): void + { + Settings::increment_drop_counter('garbage'); + $c = Settings::get_drop_counters(); + self::assertSame(0, $c['total']); + } +} From 0034531a3a97e1df46e48eec6aabe598bb157066 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 16:25:30 +0500 Subject: [PATCH 14/21] fix(settings): wp_date() respects WP timezone; clarify counter is not atomic - Replace date('Y-m-d') with wp_date('Y-m-d') in increment_drop_counter and default_drop_counters to respect WordPress timezone settings. - Update corresponding test assertions to use wp_date() for consistency. - Clarify in docblock that increment_drop_counter is not atomic under concurrent submissions; read-modify-write pattern via update_option is acceptable for low-volume dashboard counters. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- includes/class-settings.php | 9 ++++++--- tests/SettingsCounterTest.php | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/includes/class-settings.php b/includes/class-settings.php index dd3952b..b86426d 100644 --- a/includes/class-settings.php +++ b/includes/class-settings.php @@ -125,15 +125,18 @@ final class Settings } /** - * Atomically increments the drop counter. `$mode` must be 'real' or 'dry_run'; + * Increments the drop counter. `$mode` must be 'real' or 'dry_run'; * any other value is a no-op (defensive — guards against typos in callers). * Performs rolling today→yesterday on date change. + * + * Note: not atomic under concurrent CF7 submissions — `update_option` is a + * read-modify-write. Acceptable for low-volume dashboard counters. */ 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'); + $today = wp_date('Y-m-d'); if ($c['today_date'] !== $today) { $c['real_yesterday'] = $c['real_today']; $c['dry_yesterday'] = $c['dry_today']; @@ -157,7 +160,7 @@ final class Settings private static function default_drop_counters(): array { - $today = date('Y-m-d'); + $today = wp_date('Y-m-d'); return [ 'total' => 0, 'real_today' => 0, diff --git a/tests/SettingsCounterTest.php b/tests/SettingsCounterTest.php index eb195d0..b32ec27 100644 --- a/tests/SettingsCounterTest.php +++ b/tests/SettingsCounterTest.php @@ -49,7 +49,7 @@ final class SettingsCounterTest extends Cf7stgTestCase Settings::increment_drop_counter('real'); $c = Settings::get_drop_counters(); - self::assertSame(date('Y-m-d'), $c['today_date']); + self::assertSame(wp_date('Y-m-d'), $c['today_date']); self::assertSame(5, $c['real_yesterday']); self::assertSame(1, $c['real_today']); self::assertSame(6, $c['total']); @@ -64,7 +64,7 @@ final class SettingsCounterTest extends Cf7stgTestCase 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']); + self::assertSame(wp_date('Y-m-d'), $c['reset_at']); } public function test_get_dry_run_drop_default_true_when_unset(): void From 24367c1ccb3712894390ab7d32c1c279cda3f7bb Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 16:26:38 +0500 Subject: [PATCH 15/21] feat(activator): seed silent-drop options (dry-run on by default) on activate --- includes/class-activator.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/includes/class-activator.php b/includes/class-activator.php index a6d8911..eccb13e 100644 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -12,6 +12,7 @@ final class Activator { self::guard_requirements(); self::install_table(); + self::seed_silent_drop_options(); self::schedule_events(); } @@ -64,6 +65,27 @@ final class Activator update_option(self::DB_VERSION_OPTION, self::DB_VERSION); } + /** + * Seed safe defaults for silent-drop options. Uses add_option (not update_option), + * so existing values from prior versions or user customisations are preserved. + * Runs on every activate() call — fresh installs and upgrades alike. + */ + 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' => wp_date('Y-m-d'), + 'reset_at' => wp_date('Y-m-d'), + ]); + } + private static function schedule_events(): void { // Register the custom 60-second schedule here — Dispatcher::register_hooks() From 42e311c05a1591de74fc9d93c48347e8bdb74cce Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 16:30:07 +0500 Subject: [PATCH 16/21] =?UTF-8?q?feat(source):=20drop=20label=20=E2=86=92?= =?UTF-8?q?=20skip=20enqueue=20(or=20marked=20enqueue=20in=20dry-run);=20c?= =?UTF-8?q?f7stg=5Fshould=5Fenqueue=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also extends test bootstrap autoloader to resolve classes in includes/sources/ and admin/ subdirectories. --- includes/sources/class-cf7-source.php | 44 ++++++++++++++- tests/CF7SourceDropTest.php | 77 +++++++++++++++++++++++++++ tests/bootstrap.php | 16 ++++-- 3 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 tests/CF7SourceDropTest.php diff --git a/includes/sources/class-cf7-source.php b/includes/sources/class-cf7-source.php index 79dbe8d..de5f079 100644 --- a/includes/sources/class-cf7-source.php +++ b/includes/sources/class-cf7-source.php @@ -35,6 +35,17 @@ final class CF7Source implements Interface_Spam_Source $reason = $this->detect_reason($submission, $fallback_reason); $classification = Classifier::classify($fields, ['reason' => $reason]); + $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)) { @@ -54,14 +65,19 @@ final class CF7Source implements Interface_Spam_Source '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' => $classification['label'], + 'label' => $label, 'is_retro' => 0, ]); } @@ -91,4 +107,30 @@ final class CF7Source implements Interface_Spam_Source } return ''; } + + /** + * Decides what to do with a classified submission based on its label and the + * dry-run setting. Pure function — no side effects. + * + * @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']; + } + + /** + * Filterable gate — allows external code to block non-drop submissions + * via filter `cf7stg_should_enqueue`. + */ + public static function should_enqueue(string $label, array $fields, $submission): bool + { + return (bool)apply_filters('cf7stg_should_enqueue', true, $label, $fields, $submission); + } } diff --git a/tests/CF7SourceDropTest.php b/tests/CF7SourceDropTest.php new file mode 100644 index 0000000..38ad3d8 --- /dev/null +++ b/tests/CF7SourceDropTest.php @@ -0,0 +1,77 @@ +<?php +declare(strict_types=1); + +namespace Cf7stg\Tests; + +use Cf7stg\CF7Source; +use Cf7stg\Settings; + +final class CF7SourceDropTest extends Cf7stgTestCase +{ + 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_non_drop_label_client_returns_normal_enqueue(): void + { + $decision = CF7Source::decide_drop_action('client'); + self::assertSame('enqueue', $decision['action']); + self::assertNull($decision['counter_mode']); + } + + public function test_non_drop_label_spam_returns_normal_enqueue(): void + { + $decision = CF7Source::decide_drop_action('spam'); + 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)); + } + + public function test_filter_should_enqueue_passes_fields_and_submission(): void + { + $captured = []; + add_filter('cf7stg_should_enqueue', static function ($should, $label, $fields, $submission) use (&$captured) { + $captured = ['label' => $label, 'fields' => $fields, 'submission' => $submission]; + return $should; + }, 10, 4); + + $sub = new \stdClass(); + $sub->marker = 'test-submission'; + CF7Source::should_enqueue('client', ['k' => 'v'], $sub); + + self::assertSame('client', $captured['label']); + self::assertSame(['k' => 'v'], $captured['fields']); + self::assertSame($sub, $captured['submission']); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 198ab9d..af84564 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -9,9 +9,19 @@ spl_autoload_register(static function (string $class): void { $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; + $candidates = [ + __DIR__ . '/../includes/class-' . $slug . '.php', + __DIR__ . '/../includes/sources/class-' . $slug . '.php', + __DIR__ . '/../admin/class-' . $slug . '.php', + __DIR__ . '/../includes/' . $slug . '.php', + __DIR__ . '/../includes/sources/' . $slug . '.php', + __DIR__ . '/../admin/' . $slug . '.php', + ]; + foreach ($candidates as $path) { + if (is_file($path)) { + require_once $path; + return; + } } }); From 2fcebcc756548b2a8d865dc373700ce327c62b15 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 17:12:16 +0500 Subject: [PATCH 17/21] =?UTF-8?q?feat(formatter):=20[=D0=91=D0=AB=D0=9B?= =?UTF-8?q?=D0=9E=20=D0=91=D0=AB=20DROPPED]=20header=20+=20triggered-rule?= =?UTF-8?q?=20line=20in=20dry-run=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- includes/class-message-formatter.php | 24 +++++++-- tests/MessageFormatterDryRunTest.php | 77 ++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 tests/MessageFormatterDryRunTest.php diff --git a/includes/class-message-formatter.php b/includes/class-message-formatter.php index f052bf8..681a915 100644 --- a/includes/class-message-formatter.php +++ b/includes/class-message-formatter.php @@ -43,9 +43,15 @@ final class MessageFormatter $lines = []; - $label_info = self::LABEL_MAP[$class['label']] ?? self::LABEL_MAP['unclear']; - $header = ($ctx['is_retro'] ? '📂 Ретроспектива · ' : '') - . $label_info['emoji'] . ' ' . $label_info['text']; + $is_dry_run_drop = !empty($ctx['dry_run_drop']); + + if ($is_dry_run_drop) { + $header = '[БЫЛО БЫ DROPPED]'; + } else { + $label_info = self::LABEL_MAP[$class['label']] ?? self::LABEL_MAP['unclear']; + $header = ($ctx['is_retro'] ? '📂 Ретроспектива · ' : '') + . $label_info['emoji'] . ' ' . $label_info['text']; + } $lines[] = $header; $lines[] = '🌐 ' . esc_html($ctx['site_title']) . ' · форма «' . esc_html($ctx['form_title']) . '»'; @@ -60,6 +66,18 @@ final class MessageFormatter $lines[] = '⚙️ Попало в спам: ' . esc_html($ctx['reason']); $lines[] = '📊 Классификация: ' . self::format_reasons($class['reasons']); + if ($is_dry_run_drop) { + $rule = ''; + foreach ($class['reasons'] as $r) { + $text = (string)($r['text'] ?? ''); + if (strpos($text, 'hard-rule:') === 0 || strpos($text, 'score ≤') === 0) { + $rule = $text; + break; + } + } + if ($rule !== '') $lines[] = '🚫 Сработавшее правило: ' . esc_html($rule); + } + $meta_bits = []; if ($ctx['ip'] !== '') $meta_bits[] = 'IP ' . esc_html($ctx['ip']); if ($ctx['user_agent'] !== '') $meta_bits[] = 'UA ' . esc_html(self::shorten_ua($ctx['user_agent'])); diff --git a/tests/MessageFormatterDryRunTest.php b/tests/MessageFormatterDryRunTest.php new file mode 100644 index 0000000..8688d0e --- /dev/null +++ b/tests/MessageFormatterDryRunTest.php @@ -0,0 +1,77 @@ +<?php +declare(strict_types=1); + +namespace Cf7stg\Tests; + +use Cf7stg\MessageFormatter; + +final class MessageFormatterDryRunTest extends Cf7stgTestCase +{ + 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_dry_run_drop_includes_score_based_rule_line(): void + { + $ctx = $this->ctx([ + 'dry_run_drop' => true, + 'classification' => [ + 'score' => -7, + 'label' => 'drop', + 'reasons' => [ + ['sign' => '×', 'weight' => 0, 'text' => 'score ≤ -5'], + ], + ], + ]); + $r = MessageFormatter::build($ctx); + self::assertStringContainsString('Сработавшее правило: score ≤ -5', $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']); + } + + public function test_dry_run_drop_header_replaces_label_emoji(): void + { + // When dry_run_drop is true, the regular ⛔ авто-дроп header must be REPLACED, + // not duplicated. + $r = MessageFormatter::build($this->ctx(['dry_run_drop' => true])); + $first_line = strtok($r['text'], "\n"); + self::assertStringNotContainsString('авто-дроп', $first_line); + } +} From ea5666318b320f30733c99f01118179c107cd6f2 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 17:14:56 +0500 Subject: [PATCH 18/21] feat(admin): silent-drop block in settings page (dry-run, hard-rule, threshold, stats, reset) --- admin/class-settings-page.php | 19 +++++++++++ admin/views/settings.php | 62 +++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/admin/class-settings-page.php b/admin/class-settings-page.php index aded85d..d6a1886 100644 --- a/admin/class-settings-page.php +++ b/admin/class-settings-page.php @@ -16,6 +16,8 @@ final class Settings_Page add_action('admin_post_cf7stg_retro', [$this, 'handle_retro']); add_action('admin_post_cf7stg_retry_failed', [$this, 'handle_retry_failed']); add_action('admin_post_cf7stg_purge_sent', [$this, 'handle_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']); } public function add_menu(): void @@ -92,6 +94,23 @@ final class Settings_Page $this->redirect_back(['purged' => $n]); } + 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]); + } + private function guard(string $action): void { if (!current_user_can('manage_options')) wp_die('forbidden'); diff --git a/admin/views/settings.php b/admin/views/settings.php index 409e857..d536d53 100644 --- a/admin/views/settings.php +++ b/admin/views/settings.php @@ -13,6 +13,8 @@ if (($_GET['test'] ?? '') === 'err') $notice = '<div class="notice notice-error" if (isset($_GET['retro_added'])) $notice = '<div class="notice notice-success"><p>Добавлено в очередь: ' . (int)$_GET['retro_added'] . ', пропущено: ' . (int)$_GET['retro_skipped'] . '.</p></div>'; if (isset($_GET['retried'])) $notice = '<div class="notice notice-success"><p>Перезапущено с ошибкой: ' . (int)$_GET['retried'] . '.</p></div>'; if (isset($_GET['purged'])) $notice = '<div class="notice notice-success"><p>Удалено отправленных: ' . (int)$_GET['purged'] . '.</p></div>'; +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 class="wrap"> <h1>CF7 Spam → Telegram</h1> @@ -108,4 +110,64 @@ if (isset($_GET['purged'])) $notice = '<div class="notice notice-success" <?php wp_nonce_field('cf7stg_purge_sent', $nonce); ?> <button type="submit" class="button">Очистить отправленные</button> </form> + + <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> </div> From 22722ae9e98b638654596410d33e6619898d8b74 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 17:16:34 +0500 Subject: [PATCH 19/21] feat(admin): silent-drop counter line in dashboard widget --- admin/class-dashboard-widget.php | 2 ++ admin/views/dashboard-widget.php | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/admin/class-dashboard-widget.php b/admin/class-dashboard-widget.php index 04ffa09..8f89d9d 100644 --- a/admin/class-dashboard-widget.php +++ b/admin/class-dashboard-widget.php @@ -30,6 +30,8 @@ final class Dashboard_Widget $pending = Queue::count_by_status(Queue::STATUS_PENDING); $sent_24h = Queue::count_sent_last_24h(); $configured = Settings::is_configured(); + $dry_run_on = Settings::is_dry_run_drop(); + $drop_counters = Settings::get_drop_counters(); $settings_url = admin_url('options-general.php?page=cf7stg-settings'); include CF7STG_PLUGIN_DIR . 'admin/views/dashboard-widget.php'; } diff --git a/admin/views/dashboard-widget.php b/admin/views/dashboard-widget.php index 2bc3993..1a0056d 100644 --- a/admin/views/dashboard-widget.php +++ b/admin/views/dashboard-widget.php @@ -5,6 +5,8 @@ /** @var int $sent_24h */ /** @var bool $configured */ /** @var string $settings_url */ +/** @var bool $dry_run_on */ +/** @var array $drop_counters */ ?> <?php if (!$configured): ?> <p>⚙️ Плагин не настроен — укажите <b>Bot Token</b> и <b>Chat ID</b> в настройках.</p> @@ -20,6 +22,14 @@ </span> </p> +<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> + <?php if ($failed_count > 0): ?> <p style="margin-top:10px">Последние ошибки доставки (TG-API):</p> <ul style="margin:0 0 8px 18px;list-style:disc"> From 030eaca408cde0a7511fafe0f0faeea998bd18de Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 17:18:14 +0500 Subject: [PATCH 20/21] chore: bump version to 1.1.0 with silent-drop changelog --- cf7-spam-to-telegram.php | 4 ++-- readme.txt | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/cf7-spam-to-telegram.php b/cf7-spam-to-telegram.php index 47e6e06..1cdd5a8 100644 --- a/cf7-spam-to-telegram.php +++ b/cf7-spam-to-telegram.php @@ -2,7 +2,7 @@ /** * Plugin Name: CF7 Spam → Telegram * Description: Пересылает spam-заявки Contact Form 7 в Telegram-группу с эвристической оценкой. - * Version: 1.0.4 + * Version: 1.1.0 * Author: Vladimir Bryzgalov * Requires PHP: 7.4 * Requires at least: 6.0 @@ -17,7 +17,7 @@ if (!defined('ABSPATH')) exit; define('CF7STG_PLUGIN_FILE', __FILE__); define('CF7STG_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('CF7STG_PLUGIN_URL', plugin_dir_url(__FILE__)); -define('CF7STG_VERSION', '1.0.4'); +define('CF7STG_VERSION', '1.1.0'); require_once CF7STG_PLUGIN_DIR . 'includes/class-plugin.php'; \Cf7stg\Plugin::register_autoloader(); diff --git a/readme.txt b/readme.txt index 3c9c856..82b55f3 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: contact-form-7, telegram, spam, flamingo Requires at least: 6.0 Tested up to: 6.9 Requires PHP: 7.4 -Stable tag: 1.0.4 +Stable tag: 1.1.0 License: GPLv2 or later Пересылает в Telegram-группу submissions CF7, помеченные как спам, с эвристической оценкой «похоже клиент / неясно / похоже спам». @@ -24,6 +24,18 @@ License: GPLv2 or later == Changelog == += 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. + = 1.0.4 = * Fix: PhoneParser больше не склеивает цифры из соседних полей при flatten через `\n` / `\t`. * Fix: Flamingo `_from_email` используется как `your-email` только если содержит `@`; `_from_name` больше не маппится. From 714b4712adc8ec64ea84b8b745aeb8eb90a3d679 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov <vladimir.bryzgalov@gmail.com> Date: Tue, 21 Apr 2026 17:25:20 +0500 Subject: [PATCH 21/21] fix: wire is_hard_drop_enabled + drop_score_threshold settings into Classifier filter chain Settings::register_classifier_filters() registers priority-5 callbacks on cf7stg_classifier_thresholds and cf7stg_hard_drop_rules so the UI options actually influence Classifier behaviour. Called from Plugin::boot(). User filters at priority 10 override these defaults as expected. Adds 4 tests covering the wired behaviour and override priority. Also adds dry_run_drop? to MessageFormatter::build() @param docblock. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- includes/class-message-formatter.php | 3 +- includes/class-plugin.php | 3 ++ includes/class-settings.php | 23 ++++++++++++ tests/ClassifierDropTest.php | 52 ++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/includes/class-message-formatter.php b/includes/class-message-formatter.php index 681a915..08ac3ec 100644 --- a/includes/class-message-formatter.php +++ b/includes/class-message-formatter.php @@ -24,7 +24,8 @@ final class MessageFormatter * reason:string, * ip:string, * user_agent:string, - * is_retro:bool + * is_retro:bool, + * dry_run_drop?:bool * } $ctx * * @return array{text:string,reply_markup:array|null} diff --git a/includes/class-plugin.php b/includes/class-plugin.php index 6769099..2b6728f 100644 --- a/includes/class-plugin.php +++ b/includes/class-plugin.php @@ -43,6 +43,9 @@ final class Plugin public function boot(): void { + // Inject silent-drop options into Classifier filter chain + Settings::register_classifier_filters(); + // Register runtime hooks Dispatcher::register_hooks(); (new CF7Source())->register(); diff --git a/includes/class-settings.php b/includes/class-settings.php index b86426d..284739c 100644 --- a/includes/class-settings.php +++ b/includes/class-settings.php @@ -171,4 +171,27 @@ final class Settings 'reset_at' => $today, ]; } + + /** + * Register default callbacks that inject silent-drop options into the + * Classifier's filter chain. Runs at plugin boot. User filters at the + * default priority (10) override these (priority 5). + */ + public static function register_classifier_filters(): void + { + add_filter('cf7stg_classifier_thresholds', static function ($thresholds) { + if (is_array($thresholds)) { + $thresholds['drop'] = self::get_drop_score_threshold(); + } + return $thresholds; + }, 5); + + add_filter('cf7stg_hard_drop_rules', static function ($rules) { + if (is_array($rules) && !self::is_hard_drop_enabled()) { + $rules['short_latin_name'] = false; + $rules['placeholder_fields'] = false; + } + return $rules; + }, 5); + } } diff --git a/tests/ClassifierDropTest.php b/tests/ClassifierDropTest.php index 907dfef..163cc3d 100644 --- a/tests/ClassifierDropTest.php +++ b/tests/ClassifierDropTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Cf7stg\Tests; use Cf7stg\Classifier; +use Cf7stg\Settings; final class ClassifierDropTest extends Cf7stgTestCase { @@ -283,4 +284,55 @@ final class ClassifierDropTest extends Cf7stgTestCase $r = Classifier::classify($fields, ['reason' => 'Akismet']); self::assertSame('drop', $r['label']); } + + public function test_hard_drop_disabled_via_settings_skips_drop(): void + { + Settings::set_hard_drop_enabled(false); + Settings::register_classifier_filters(); + $fields = require __DIR__ . '/fixtures/spam-screenshot-1.php'; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + // With hard-rule disabled, screenshot-1 has no positive signals AND score isn't ≤ -5 + // (placeholders by themselves give no score penalty), so label should NOT be 'drop'. + self::assertNotSame('drop', $r['label']); + } + + public function test_drop_score_threshold_from_settings_applied(): void + { + Settings::set_drop_score_threshold(-3); + Settings::register_classifier_filters(); + // URL gives -3 score, no positive signals, no hard-rule trigger. + // With threshold lowered to -3, this should fire score-based drop. + $fields = ['msg' => 'http://x.com']; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertSame('drop', $r['label']); + } + + public function test_settings_threshold_can_be_overridden_by_user_filter(): void + { + Settings::set_drop_score_threshold(-3); + Settings::register_classifier_filters(); + // User filter at priority 10 should override Settings default at priority 5. + add_filter('cf7stg_classifier_thresholds', static function ($t) { + $t['drop'] = -10; + return $t; + }, 10); + $fields = ['msg' => 'http://x.com']; // score = -3 + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + // -3 is NOT ≤ -10, so no drop + self::assertNotSame('drop', $r['label']); + } + + public function test_filter_disables_placeholder_fields_rule(): void + { + add_filter('cf7stg_hard_drop_rules', static function ($rules) { + $rules['placeholder_fields'] = false; + return $rules; + }); + // Form WITHOUT name field — digit-only values are valid placeholders (^[A-Za-z0-9]{1,2}$) + // but FieldDetector::find_name rejects them (has digits), so short_latin_name cannot fire. + // With placeholder_fields rule also disabled → no hard-rule fires → no drop. + $fields = ['a' => '11', 'b' => '22', 'c' => '33', 'd' => '44']; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertNotSame('drop', $r['label']); + } }