Merge feat/silent-drop: silent drop label + dry-run + counters (1.1.0)

Adds Classifier label 'drop' via hard-rule (no positive signals + short
latin name OR ≥3 placeholder fields) or score ≤ -5. Drop submissions are
silently skipped from the TG queue (or marked [БЫЛО БЫ DROPPED] when
dry-run mode is on, default after install). Settings page block + dashboard
widget counter. New filters: cf7stg_should_enqueue, cf7stg_hard_drop_rules.

Tests: 53 → 118 (+65). Spec: docs/superpowers/specs/2026-04-21-cf7stg-silent-drop-design.md.
Plan: docs/superpowers/plans/2026-04-21-cf7stg-silent-drop.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vladimir Bryzgalov
2026-04-21 18:14:40 +05:00
24 changed files with 3069 additions and 25 deletions
+2
View File
@@ -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';
}
+19
View File
@@ -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');
+10
View File
@@ -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">
+62
View File
@@ -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>
+2 -2
View File
@@ -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();
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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()
+156
View File
@@ -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 = [
@@ -46,6 +52,19 @@ final class Classifier
'rambler.ru', 'gmail.com',
];
/**
* 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',
'_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 / Неизвестно
@@ -140,6 +159,15 @@ final class Classifier
$label = 'spam';
}
// Drop has highest priority (overrides client/unclear/spam/honeypot)
$drop_trigger = self::compute_hard_drop_trigger($fields);
$is_drop_by_score = isset($thresholds['drop']) && $score <= (int)$thresholds['drop'];
if ($drop_trigger !== null || $is_drop_by_score) {
$label = 'drop';
$reason_text = $drop_trigger ?? ('score ≤ ' . (int)$thresholds['drop']);
$reasons[] = ['sign' => '×', 'weight' => 0, 'text' => $reason_text];
}
return ['score' => $score, 'label' => $label, 'reasons' => $reasons];
}
@@ -215,4 +243,132 @@ 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
* @return bool True if any value contains a syntactically valid email
*/
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_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;
}
}
}
}
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).
*
* 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
{
$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);
}
/**
* 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;
}
/**
* 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
* 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
{
return self::compute_hard_drop_trigger($fields) !== null;
}
}
+30 -4
View File
@@ -11,6 +11,7 @@ final class MessageFormatter
'client' => ['emoji' => '🟢', 'text' => 'похоже клиент'],
'unclear' => ['emoji' => '🟡', 'text' => 'неясно'],
'spam' => ['emoji' => '🔴', 'text' => 'похоже спам'],
'drop' => ['emoji' => '⛔', 'text' => 'авто-дроп'],
];
/**
@@ -23,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}
@@ -42,9 +44,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']) . '»';
@@ -59,6 +67,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']));
@@ -107,6 +127,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));
+3
View File
@@ -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();
+118
View File
@@ -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,117 @@ 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);
}
/**
* 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 = wp_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 = wp_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,
];
}
/**
* 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);
}
}
+43 -1
View File
@@ -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);
}
}
+13 -1
View File
@@ -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` больше не маппится.
+77
View File
@@ -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']);
}
}
+15
View File
@@ -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();
}
}
+338
View File
@@ -0,0 +1,338 @@
<?php
declare(strict_types=1);
namespace Cf7stg\Tests;
use Cf7stg\Classifier;
use Cf7stg\Settings;
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([]));
}
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));
}
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));
}
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));
}
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) → 5 placeholders.
// text-790: 10 цифр — не placeholder. acceptance-data: служебное → исключено.
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
{
$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));
}
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']);
}
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']);
}
}
+6 -5
View File
@@ -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
{
@@ -21,16 +20,18 @@ final class ClassifierTest extends TestCase
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)
$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
+77
View File
@@ -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);
}
}
+25 -2
View File
@@ -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']);
}
+105
View File
@@ -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(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']);
}
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(wp_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']);
}
}
+142 -10
View File
@@ -1,27 +1,156 @@
<?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';
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;
}
}
});
// 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,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;
}
}
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']) ?: ($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);
}
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,
'seq' => Cf7stgWpShimState::$next_seq++,
];
return true;
}
}
if (!function_exists('remove_all_filters')) {
function remove_all_filters($tag, $priority = false) {
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;
}
}
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 +163,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); }
}
+11
View File
@@ -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',
];
+11
View File
@@ -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',
];
+9
View File
@@ -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?',
];