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 --type child-of
+# Между Task N и Task N+1 (если N блокирует N+1):
+bd dep add --type blocks
+```
+
+Перед стартом Task: `bd update --claim`. После завершения: `bd close --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
+ */
+ public static array $options = [];
+ /** @var array> */
+ 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
+ '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
+ '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
+ '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
+ '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
+ 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
+ */
+ 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 */
+ public static array $enqueued = [];
+ /** @var array */
+ 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
+ ['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 = '
Настройки silent drop сохранены.
';
+if (isset($_GET['drop_reset'])) $notice = '
Счётчики silent drop сброшены.
';
+```
+
+И перед закрывающим `` (т.е. в самом конце шаблона перед строкой 111) вставить:
+
+```php
+
+
+
Silent drop
+
+ Заявки, удовлетворяющие правилам, не отправляются в Telegram.
+ Подробности правил — в docs/superpowers/specs/2026-04-21-cf7stg-silent-drop-design.md.
+
+
+
+
+
+
+
Статистика
+
+ Всего дропнуто:
+ Сегодня (real): · сегодня (dry-run):
+ Вчера (real): · вчера (dry-run):
+ Счётчик с:
+
@@ -108,4 +110,64 @@ if (isset($_GET['purged'])) $notice = '
+
+
+
+
Silent drop
+
+ Заявки, удовлетворяющие правилам, не отправляются в Telegram.
+ Подробности правил — в docs/superpowers/specs/2026-04-21-cf7stg-silent-drop-design.md.
+
+
+
+
+
+
+
Статистика
+
+ Всего дропнуто:
+ Сегодня (real): · сегодня (dry-run):
+ Вчера (real): · вчера (dry-run):
+ Счётчик с:
+