Files
cf7-spam-to-telegram/docs/superpowers/plans/2026-04-17-cf7-spam-to-telegram.md
T
Vladimir Bryzgalov 22239321d0 fix(activator): register cron_schedules filter during activation
Dispatcher::register_hooks() runs on plugins_loaded, which is AFTER
register_activation_hook fires. The custom recurrence 'cf7stg_every_minute'
was therefore unknown to wp_schedule_event during activation, and the
'cf7stg_dispatch' event silently failed to schedule — queue items never
got dispatched.

Pilot caught this on washanyanya.ru (23 items queued, 0 sent, 0 failed —
no cron hook firing at all).

Adding the filter directly inside Activator::schedule_events() makes the
custom schedule available in the same request as the wp_schedule_event
call, regardless of bootstrap order.

After updating the plugin, deactivate + reactivate is required to
re-trigger Activator::activate() so events get (re)scheduled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:26:06 +05:00

93 KiB
Raw Blame History

CF7 Spam → Telegram 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: Build a WordPress plugin that forwards CF7 spam-submissions to a shared Telegram group with heuristic client/unclear/spam scoring, so operators can spot real inquiries wrongly filtered as spam.

Architecture: Standalone WP plugin (does not depend on cf7-telegram). Pure-logic classes (Classifier, PhoneParser, DomainFormatter, MessageFormatter) are unit-tested via PHPUnit without WP. Integration layer (Queue, Dispatcher, CF7Source, TelegramClient, admin UI) is manually verified through a "test message" button and a pilot on washanyanya.ru. Queue persists across WP-Cron ticks, retries with exponential backoff, dashboard widget shown only when there are failed items.

Tech Stack: PHP 7.4+, WordPress 6.9+, Contact Form 7 + Flamingo, Telegram Bot API, PHPUnit 9 (dev-only via Composer), wp_remote_post, WP-Cron.

Reference: docs/superpowers/specs/2026-04-17-cf7-spam-to-telegram-design.md


File Structure

cf7-spam-to-telegram/
├── cf7-spam-to-telegram.php         # Main plugin file (WP header, bootstrap)
├── uninstall.php                     # Wipe table + options on uninstall
├── composer.json                     # dev-only (phpunit)
├── phpunit.xml                       # PHPUnit config
├── .gitignore                        # vendor/, composer.lock, .DS_Store, .phpunit.result.cache
├── includes/
│   ├── class-plugin.php              # Singleton, autoloader, hook registration
│   ├── class-activator.php           # Activation: create table, defaults, cron
│   ├── class-crypto.php              # Encrypt/decrypt token via AUTH_KEY
│   ├── class-settings.php            # Options wrapper, constant override
│   ├── class-queue.php               # Queue CRUD + dedup + cleanup
│   ├── class-telegram-exception.php  # Exception with is_permanent flag
│   ├── class-telegram-client.php     # wp_remote_post wrapper
│   ├── class-dispatcher.php          # WP-Cron tick, retry/backoff
│   ├── class-classifier.php          # Heuristic scoring
│   ├── class-phone-parser.php        # RU phone normalization
│   ├── class-domain-formatter.php    # Punycode → Cyrillic
│   ├── class-message-formatter.php   # Build HTML message + inline button
│   ├── class-flamingo-helper.php     # Extract fields/reason from Flamingo posts
│   ├── class-retro-importer.php      # Retro queueing from Flamingo
│   └── sources/
│       ├── interface-spam-source.php
│       └── class-cf7-source.php      # CF7 hooks (wpcf7_spam, wpcf7_submission)
├── admin/
│   ├── class-settings-page.php       # Settings page under Settings menu
│   ├── class-dashboard-widget.php    # Conditional dashboard widget
│   └── views/
│       ├── settings.php
│       └── dashboard-widget.php
├── tests/
│   ├── bootstrap.php
│   ├── ClassifierTest.php
│   ├── PhoneParserTest.php
│   ├── DomainFormatterTest.php
│   └── MessageFormatterTest.php
├── docs/superpowers/
│   ├── specs/2026-04-17-cf7-spam-to-telegram-design.md
│   └── plans/2026-04-17-cf7-spam-to-telegram.md
└── readme.txt

Task 1: Composer + PHPUnit setup

Files:

  • Create: composer.json

  • Create: phpunit.xml

  • Create: tests/bootstrap.php

  • Create: .gitattributes (exclude tests/ from archive)

  • Step 1: Create composer.json

{
  "name": "sidelkin/cf7-spam-to-telegram",
  "description": "Forwards CF7 spam submissions to Telegram",
  "type": "wordpress-plugin",
  "license": "GPL-2.0-or-later",
  "require": {
    "php": ">=7.4"
  },
  "require-dev": {
    "phpunit/phpunit": "^9.6"
  },
  "autoload-dev": {
    "psr-4": {
      "Cf7stg\\Tests\\": "tests/"
    }
  },
  "scripts": {
    "test": "phpunit"
  },
  "config": {
    "sort-packages": true
  }
}
  • Step 2: Create phpunit.xml
<?xml version="1.0"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd"
         bootstrap="tests/bootstrap.php"
         colors="true"
         cacheResultFile=".phpunit.result.cache">
    <testsuites>
        <testsuite name="unit">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>
  • Step 3: Create tests/bootstrap.php

This bootstrap gives tests access to the pure-logic classes without loading WordPress.

<?php
declare(strict_types=1);

// Simple autoloader that mirrors the runtime plugin autoloader,
// mapping Cf7stg\ClassName → includes/class-class-name.php
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;
    }
});

// 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; }
}
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; }
}
  • Step 4: Create .gitattributes
/tests export-ignore
/docs export-ignore
/phpunit.xml export-ignore
/composer.json export-ignore
/composer.lock export-ignore
/.gitignore export-ignore
/.gitattributes export-ignore
  • Step 5: Install dependencies and verify PHPUnit runs

Run:

cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/cf7-spam-to-telegram"
composer install
./vendor/bin/phpunit --version

Expected: PHPUnit 9.x.x by Sebastian Bergmann.

  • Step 6: Commit
git add composer.json phpunit.xml tests/bootstrap.php .gitattributes
git commit -m "chore: add composer + phpunit dev scaffolding"

Task 2: DomainFormatter — tests

Files:

  • Create: tests/DomainFormatterTest.php

  • Step 1: Write the failing tests

<?php
declare(strict_types=1);

namespace Cf7stg\Tests;

use Cf7stg\DomainFormatter;
use PHPUnit\Framework\TestCase;

final class DomainFormatterTest extends TestCase
{
    public function test_decodes_punycode_to_cyrillic(): void
    {
        self::assertSame(
            'вашаняня.рф',
            DomainFormatter::humanize('xn--80aae0ca7d8bb.xn--p1ai')
        );
    }

    public function test_latin_domain_unchanged(): void
    {
        self::assertSame(
            'washanyanya.ru',
            DomainFormatter::humanize('washanyanya.ru')
        );
    }

    public function test_strips_scheme_and_path(): void
    {
        self::assertSame(
            'washanyanya.ru',
            DomainFormatter::humanize('https://washanyanya.ru/contact/?a=1')
        );
    }

    public function test_strips_leading_www(): void
    {
        self::assertSame(
            'washanyanya.ru',
            DomainFormatter::humanize('www.washanyanya.ru')
        );
    }

    public function test_invalid_input_returns_empty_string(): void
    {
        self::assertSame('', DomainFormatter::humanize(''));
    }

    public function test_broken_idn_returns_original(): void
    {
        // Looks like IDN but is garbage; idn_to_utf8 returns false → we fall back
        self::assertSame('xn--', DomainFormatter::humanize('xn--'));
    }
}
  • Step 2: Run tests to verify they fail

Run: ./vendor/bin/phpunit --filter DomainFormatterTest Expected: FAIL with "Class Cf7stg\DomainFormatter does not exist" or autoloader errors.


Task 3: DomainFormatter — implementation

Files:

  • Create: includes/class-domain-formatter.php

  • Step 1: Implement DomainFormatter::humanize

<?php
declare(strict_types=1);

namespace Cf7stg;

final class DomainFormatter
{
    public static function humanize(string $input): string
    {
        $input = trim($input);
        if ($input === '') {
            return '';
        }

        // Normalize: strip scheme, userinfo, path, query, fragment
        $host = $input;
        if (preg_match('#^[a-z][a-z0-9+\-.]*://#i', $host)) {
            $parsed = parse_url($host);
            $host = $parsed['host'] ?? $host;
        }
        // Strip any remaining path/query
        $host = preg_replace('~[/?#].*$~', '', $host) ?? $host;
        // Strip leading www.
        $host = preg_replace('/^www\./i', '', $host) ?? $host;

        if ($host === '') {
            return '';
        }

        // Punycode → Unicode per label (.xn-- pieces may appear on only one side)
        if (function_exists('idn_to_utf8') && strpos($host, 'xn--') !== false) {
            $variant = defined('INTL_IDNA_VARIANT_UTS46')
                ? INTL_IDNA_VARIANT_UTS46
                : 0;
            $decoded = @idn_to_utf8($host, IDNA_DEFAULT, $variant);
            if (is_string($decoded) && $decoded !== '') {
                return $decoded;
            }
        }

        return $host;
    }
}
  • Step 2: Run tests to verify pass

Run: ./vendor/bin/phpunit --filter DomainFormatterTest Expected: PASS all 6 assertions.

  • Step 3: Commit
git add tests/DomainFormatterTest.php includes/class-domain-formatter.php
git commit -m "feat(domain-formatter): decode punycode hosts to Cyrillic with fallbacks"

Task 4: PhoneParser — tests

Files:

  • Create: tests/PhoneParserTest.php

  • Step 1: Write the failing tests

<?php
declare(strict_types=1);

namespace Cf7stg\Tests;

use Cf7stg\PhoneParser;
use PHPUnit\Framework\TestCase;

final class PhoneParserTest extends TestCase
{
    public function test_ru_plus7_formatted(): void
    {
        $r = PhoneParser::parse('+7 (903) 123-45-67');
        self::assertSame('+79031234567', $r['e164']);
        self::assertSame('+7 (903) 123-45-67', $r['pretty']);
        self::assertTrue($r['is_russian']);
    }

    public function test_ru_leading_eight(): void
    {
        $r = PhoneParser::parse('89031234567');
        self::assertSame('+79031234567', $r['e164']);
        self::assertSame('+7 (903) 123-45-67', $r['pretty']);
        self::assertTrue($r['is_russian']);
    }

    public function test_ru_bare_ten_digits(): void
    {
        $r = PhoneParser::parse('9031234567');
        self::assertSame('+79031234567', $r['e164']);
        self::assertTrue($r['is_russian']);
    }

    public function test_foreign_us_number(): void
    {
        $r = PhoneParser::parse('+1 555 123 4567');
        self::assertSame('+15551234567', $r['e164']);
        self::assertFalse($r['is_russian']);
    }

    public function test_embedded_in_text(): void
    {
        $r = PhoneParser::parse('Позвоните мне: +7-903-123-45-67, после 18:00');
        self::assertSame('+79031234567', $r['e164']);
        self::assertTrue($r['is_russian']);
    }

    public function test_returns_first_of_multiple(): void
    {
        $r = PhoneParser::parse('Домашний 84951234567, мобильный 89031234567');
        self::assertSame('+74951234567', $r['e164']);
    }

    public function test_garbage_returns_null(): void
    {
        self::assertNull(PhoneParser::parse('позвоните!!!'));
    }

    public function test_empty_returns_null(): void
    {
        self::assertNull(PhoneParser::parse(''));
    }

    public function test_too_short_returns_null(): void
    {
        self::assertNull(PhoneParser::parse('12345'));
    }

    public function test_ru_moscow_landline(): void
    {
        $r = PhoneParser::parse('+7 (495) 123-45-67');
        self::assertSame('+74951234567', $r['e164']);
        self::assertTrue($r['is_russian']);
    }
}
  • Step 2: Run tests to verify they fail

Run: ./vendor/bin/phpunit --filter PhoneParserTest Expected: FAIL — class does not exist.


Task 5: PhoneParser — implementation

Files:

  • Create: includes/class-phone-parser.php

  • Step 1: Implement PhoneParser::parse

<?php
declare(strict_types=1);

namespace Cf7stg;

final class PhoneParser
{
    /**
     * Extract and normalize the first phone number from arbitrary text.
     *
     * @return array{e164:string,pretty:string,is_russian:bool}|null
     */
    public static function parse(string $text): ?array
    {
        $text = (string)$text;
        if ($text === '') {
            return null;
        }

        // Find a run of digits, spaces, dashes, parens, dots and a leading '+',
        // with at least 10 digits in total. Walk candidates left-to-right and
        // pick the first that yields 10 or 11 digits.
        if (!preg_match_all('/\+?[\d][\d\s().\-]{8,}/u', $text, $m)) {
            return null;
        }

        foreach ($m[0] as $candidate) {
            $digits = preg_replace('/\D+/', '', $candidate) ?? '';
            $plus = strpos($candidate, '+') === 0;

            // Russian detection & normalization
            if (!$plus && strlen($digits) === 11 && $digits[0] === '8') {
                $digits = '7' . substr($digits, 1);
            }
            if (!$plus && strlen($digits) === 10 && preg_match('/^[3-9]/', $digits)) {
                // Bare 10-digit RU mobile/landline area code
                $digits = '7' . $digits;
            }

            if (strlen($digits) < 10 || strlen($digits) > 15) {
                continue;
            }

            $e164 = '+' . $digits;
            $is_ru = (strlen($digits) === 11 && $digits[0] === '7');

            return [
                'e164'       => $e164,
                'pretty'     => self::format_pretty($e164, $is_ru),
                'is_russian' => $is_ru,
            ];
        }

        return null;
    }

    private static function format_pretty(string $e164, bool $is_ru): string
    {
        if ($is_ru) {
            // +7 XXX XXX-XX-XX → "+7 (XXX) XXX-XX-XX"
            $d = substr($e164, 2);
            return sprintf(
                '+7 (%s) %s-%s-%s',
                substr($d, 0, 3),
                substr($d, 3, 3),
                substr($d, 6, 2),
                substr($d, 8, 2)
            );
        }
        return $e164;
    }
}
  • Step 2: Run tests to verify pass

Run: ./vendor/bin/phpunit --filter PhoneParserTest Expected: PASS all 10 assertions.

  • Step 3: Commit
git add tests/PhoneParserTest.php includes/class-phone-parser.php
git commit -m "feat(phone-parser): normalize RU phones and extract first candidate"

Task 6: Classifier — tests (part 1, basic scoring)

Files:

  • Create: tests/ClassifierTest.php

  • Step 1: Write the failing tests

<?php
declare(strict_types=1);

namespace Cf7stg\Tests;

use Cf7stg\Classifier;
use PHPUnit\Framework\TestCase;

final class ClassifierTest extends TestCase
{
    public function test_real_client_inquiry_is_green(): void
    {
        $fields = [
            'your-name'    => 'Анна Петровна',
            'your-tel'     => '+7 903 123-45-67',
            'your-email'   => 'anna@mail.ru',
            'your-message' => 'Нужна сиделка для бабушки после инсульта, пожалуйста перезвоните.',
        ];
        $r = Classifier::classify($fields, ['reason' => 'Akismet']);
        self::assertSame('client', $r['label']);
        self::assertGreaterThanOrEqual(3, $r['score']);
    }

    public function test_seo_spam_with_url_is_red(): void
    {
        $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']);
    }

    public function test_honeypot_forces_spam_even_with_phone(): void
    {
        $fields = [
            'your-name'    => 'Анна',
            'your-tel'     => '+79031234567',
            'your-message' => 'сиделка для мамы',
        ];
        $r = Classifier::classify($fields, ['reason' => 'Honeypot']);
        self::assertSame('spam', $r['label']);
    }

    public function test_empty_fields_are_unclear(): void
    {
        $fields = [
            'your-email' => 'a@b.com',
        ];
        $r = Classifier::classify($fields, ['reason' => 'CF7 rules']);
        self::assertSame('unclear', $r['label']);
    }

    public function test_multiple_keywords_capped_at_minus_six(): void
    {
        $fields = [
            'your-message' => 'SEO, SEO, SEO, backlinks, crypto, casino, займ, bitcoin',
        ];
        $r = Classifier::classify($fields, ['reason' => 'Akismet']);
        // 8 hits × -2 would be -16; cap is -6
        self::assertGreaterThanOrEqual(-10, $r['score']); // keyword part >= -6, plus -3 for URL? none here
    }

    public function test_ru_domain_email_adds_one(): void
    {
        $fields = [
            'your-name'    => 'Иван',
            'your-email'   => 'ivan@yandex.ru',
            'your-message' => 'Здравствуйте, интересует услуга сиделки',
        ];
        $r = Classifier::classify($fields, ['reason' => 'Akismet']);
        $hasRuMail = false;
        foreach ($r['reasons'] as $reason) {
            if (strpos($reason['text'], 'ру-домен') !== false) {
                $hasRuMail = true;
            }
        }
        self::assertTrue($hasRuMail);
    }

    public function test_reasons_list_contains_sign_and_weight(): void
    {
        $fields = [
            'your-tel' => '+79031234567',
        ];
        $r = Classifier::classify($fields, ['reason' => 'Akismet']);
        self::assertNotEmpty($r['reasons']);
        self::assertArrayHasKey('sign', $r['reasons'][0]);
        self::assertArrayHasKey('weight', $r['reasons'][0]);
        self::assertArrayHasKey('text', $r['reasons'][0]);
    }

    public function test_latin_only_name_penalty(): void
    {
        $fields = [
            'your-name'    => 'John Smith',
            'your-message' => 'please help me with website ranking',
        ];
        $r = Classifier::classify($fields, ['reason' => 'Akismet']);
        $hasLatinPenalty = false;
        foreach ($r['reasons'] as $reason) {
            if ($reason['sign'] === '-' && strpos($reason['text'], 'латиница') !== false) {
                $hasLatinPenalty = true;
            }
        }
        self::assertTrue($hasLatinPenalty);
    }

    public function test_url_in_any_field_triggers_minus_three(): void
    {
        $fields = [
            'your-name'    => 'Ольга',
            'your-message' => 'посмотрите https://мойсайт.рф',
        ];
        $r = Classifier::classify($fields, ['reason' => 'Akismet']);
        $hasUrl = false;
        foreach ($r['reasons'] as $reason) {
            if (strpos($reason['text'], 'URL') !== false) {
                $hasUrl = true;
                self::assertSame('-', $reason['sign']);
                self::assertSame(3, $reason['weight']);
            }
        }
        self::assertTrue($hasUrl);
    }

    public function test_thresholds_client_edge(): void
    {
        // Score exactly 3 → client
        $fields = ['your-tel' => '+79031234567'];
        $r = Classifier::classify($fields, ['reason' => 'Akismet']);
        self::assertSame(3, $r['score']);
        self::assertSame('client', $r['label']);
    }

    public function test_thresholds_unclear_between_neg_one_and_two(): void
    {
        $fields = ['your-name' => 'Олег', 'your-message' => 'короткий'];
        $r = Classifier::classify($fields, ['reason' => 'Akismet']);
        self::assertGreaterThan(-2, $r['score']);
        self::assertLessThan(3, $r['score']);
        self::assertSame('unclear', $r['label']);
    }

    public function test_keyword_matching_case_insensitive(): void
    {
        $fields = ['your-message' => 'Продаём ВИАГРА с доставкой'];
        $r = Classifier::classify($fields, ['reason' => 'Akismet']);
        $matched = false;
        foreach ($r['reasons'] as $reason) {
            if (strpos($reason['text'], 'виагра') !== false) $matched = true;
        }
        self::assertTrue($matched);
    }
}
  • Step 2: Run tests to verify they fail

Run: ./vendor/bin/phpunit --filter ClassifierTest Expected: FAIL — class does not exist.


Task 7: Classifier — implementation

Files:

  • Create: includes/class-classifier.php

  • Step 1: Implement Classifier::classify

<?php
declare(strict_types=1);

namespace Cf7stg;

final class Classifier
{
    public const DEFAULT_WEIGHTS = [
        'phone_ru'         => 3,
        'cyrillic_name'    => 1,
        'ru_email_domain'  => 1,
        'meaningful_text'  => 1,
        'url_present'      => -3,
        'keyword_hit'      => -2,
        'keyword_cap'      => -6,
        'latin_only'       => -1,
        'honeypot'         => -5,
    ];

    public const DEFAULT_THRESHOLDS = [
        'client' => 3,
        'spam'   => -2,
    ];

    public const DEFAULT_KEYWORDS = [
        // SEO / продвижение
        'seo', 'сео', 'продвижение сайта', 'backlinks', 'обратные ссылки', 'позиции в поиске',
        // Finance / earning
        'bitcoin', 'биткоин', 'crypto', 'криптовалют', 'forex', 'форекс', 'trading', 'трейдинг',
        'loan', 'займ', 'микрозайм', 'инвестиции', 'заработок онлайн', 'удалённая работа без',
        'mlm', 'сетевой маркетинг',
        // Casino
        'casino', 'казино', 'ставки', 'betting', 'букмекер',
        // Diet / supplements
        'бад', 'биодобав', 'супер фуд', 'super food', 'детокс', 'похудение', 'похудет',
        'диетическ', 'жиросжиг',
        // Pharma
        'viagra', 'виагра', 'cialis', 'сиалис', 'pharmacy', 'фарма', 'дженерик',
        // Adult
        'porn', 'xxx', 'adult', 'porno', 'порно', 'эскорт', 'проститу',
        'секс-знакомств', 'интим-услуг',
    ];

    public const RU_EMAIL_DOMAINS = [
        'yandex.ru', 'ya.ru', 'mail.ru', 'list.ru', 'inbox.ru', 'bk.ru',
        'rambler.ru', 'gmail.com',
    ];

    /**
     * @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 / Неизвестно
     * @return array{score:int,label:string,reasons:array<int,array{sign:string,weight:int,text:string}>}
     */
    public static function classify(array $fields, array $meta = []): array
    {
        $weights = apply_filters('cf7stg_classifier_weights', self::DEFAULT_WEIGHTS);
        $keywords = apply_filters('cf7stg_classifier_keywords', self::DEFAULT_KEYWORDS);
        $thresholds = apply_filters('cf7stg_classifier_thresholds', self::DEFAULT_THRESHOLDS);

        $text_all = self::concat_fields($fields);
        $name = self::pick_field($fields, ['your-name', 'name', 'имя', 'fio']);
        $email = self::pick_field($fields, ['your-email', 'email', 'e-mail', 'mail']);
        $message = self::pick_field($fields, ['your-message', 'message', 'comment', 'сообщение', 'вопрос']);

        $score = 0;
        $reasons = [];

        // +3 RU phone
        if (PhoneParser::parse($text_all) !== null) {
            $parsed = PhoneParser::parse($text_all);
            if ($parsed !== null && $parsed['is_russian']) {
                $score += (int)$weights['phone_ru'];
                $reasons[] = ['sign' => '+', 'weight' => (int)$weights['phone_ru'], 'text' => 'телефон RU'];
            }
        }

        // +1 cyrillic name (no digits, no URL, some cyrillic)
        if ($name !== '' && self::is_cyrillic_name($name)) {
            $score += (int)$weights['cyrillic_name'];
            $reasons[] = ['sign' => '+', 'weight' => (int)$weights['cyrillic_name'], 'text' => 'кириллическое имя'];
        }

        // +1 RU email domain
        if ($email !== '' && self::is_ru_email_domain($email)) {
            $score += (int)$weights['ru_email_domain'];
            $reasons[] = ['sign' => '+', 'weight' => (int)$weights['ru_email_domain'], 'text' => 'ру-домен e-mail'];
        }

        // +1 meaningful text (cyrillic, >10 chars, not just a link)
        if ($message !== '' && self::is_meaningful_text($message)) {
            $score += (int)$weights['meaningful_text'];
            $reasons[] = ['sign' => '+', 'weight' => (int)$weights['meaningful_text'], 'text' => 'осмысленный текст'];
        }

        // -3 URL anywhere
        if (self::contains_url($text_all)) {
            $score += (int)$weights['url_present'];
            $reasons[] = ['sign' => '-', 'weight' => abs((int)$weights['url_present']), 'text' => 'URL в сообщении'];
        }

        // -2 per keyword, capped at -6
        $hits = self::keyword_hits($text_all, $keywords);
        if ($hits) {
            $per = (int)$weights['keyword_hit']; // negative
            $cap = (int)$weights['keyword_cap']; // negative
            $applied = max(count($hits) * $per, $cap);
            $score += $applied;
            foreach ($hits as $kw) {
                $reasons[] = ['sign' => '-', 'weight' => abs($per), 'text' => 'ключевое слово: ' . $kw];
                if (count(array_filter($reasons, static fn($r) => strpos($r['text'], 'ключевое слово') === 0)) >= 3) {
                    break; // keep reasons list readable
                }
            }
        }

        // -1 latin-only name or message
        if (($name !== '' && self::is_latin_only($name)) || ($message !== '' && self::is_latin_only($message))) {
            $score += (int)$weights['latin_only'];
            $reasons[] = ['sign' => '-', 'weight' => abs((int)$weights['latin_only']), 'text' => 'только латиница'];
        }

        // -5 honeypot (weighted sign AND hard override)
        $honeypot_tripped = (($meta['reason'] ?? '') === 'Honeypot');
        if ($honeypot_tripped) {
            $score += (int)$weights['honeypot'];
            $reasons[] = ['sign' => '-', 'weight' => abs((int)$weights['honeypot']), 'text' => 'сработал honeypot'];
        }

        $label = 'unclear';
        if ($score >= (int)$thresholds['client']) {
            $label = 'client';
        } elseif ($score <= (int)$thresholds['spam']) {
            $label = 'spam';
        }
        // Honeypot is a hard override: a filled honeypot field means a bot,
        // regardless of how "human" the other fields look.
        if ($honeypot_tripped) {
            $label = 'spam';
        }

        return ['score' => $score, 'label' => $label, 'reasons' => $reasons];
    }

    private static function concat_fields(array $fields): string
    {
        $out = [];
        foreach ($fields as $v) {
            if (is_array($v)) {
                $out[] = implode(' ', array_map('strval', $v));
            } else {
                $out[] = (string)$v;
            }
        }
        return implode("\n", $out);
    }

    private static function pick_field(array $fields, array $keys): string
    {
        foreach ($keys as $k) {
            if (isset($fields[$k])) {
                $v = $fields[$k];
                if (is_array($v)) $v = implode(' ', $v);
                return trim((string)$v);
            }
        }
        return '';
    }

    private static function is_cyrillic_name(string $s): bool
    {
        if (preg_match('/[0-9]/u', $s)) return false;
        if (preg_match('#https?://|www\.#i', $s)) return false;
        return (bool)preg_match('/[а-яёА-ЯЁ]/u', $s);
    }

    private static function is_ru_email_domain(string $email): bool
    {
        if (!preg_match('/@([^@\s]+)$/', trim($email), $m)) return false;
        $domain = strtolower($m[1]);
        return in_array($domain, self::RU_EMAIL_DOMAINS, true);
    }

    private static function is_meaningful_text(string $s): bool
    {
        if (mb_strlen($s) < 10) return false;
        if (preg_match('/^\s*https?:\/\/\S+\s*$/i', $s)) return false;
        return (bool)preg_match('/[а-яёА-ЯЁ]{3,}/u', $s);
    }

    private static function contains_url(string $s): bool
    {
        return (bool)preg_match('#(https?://|www\.)\S+#i', $s);
    }

    private static function is_latin_only(string $s): bool
    {
        $s = trim($s);
        if ($s === '') return false;
        if (!preg_match('/[A-Za-z]/', $s)) return false;
        return !preg_match('/[а-яёА-ЯЁ]/u', $s);
    }

    private static function keyword_hits(string $text, array $keywords): array
    {
        $hits = [];
        $text_lower = mb_strtolower($text);
        foreach ($keywords as $kw) {
            $needle = mb_strtolower($kw);
            if ($needle === '') continue;
            if (mb_strpos($text_lower, $needle) !== false) {
                $hits[] = $kw;
            }
        }
        return $hits;
    }
}
  • Step 2: Run tests to verify pass

Run: ./vendor/bin/phpunit --filter ClassifierTest Expected: PASS all 12 assertions.

  • Step 3: Commit
git add tests/ClassifierTest.php includes/class-classifier.php
git commit -m "feat(classifier): weighted heuristic for client/unclear/spam scoring"

Task 8: MessageFormatter — tests

Files:

  • Create: tests/MessageFormatterTest.php

  • Step 1: Write the failing tests

<?php
declare(strict_types=1);

namespace Cf7stg\Tests;

use Cf7stg\MessageFormatter;
use Cf7stg\Classifier;
use PHPUnit\Framework\TestCase;

final class MessageFormatterTest extends TestCase
{
    public function test_full_client_payload(): void
    {
        $fields = [
            'your-name'    => 'Анна Петровна',
            'your-tel'     => '+79031234567',
            'your-email'   => 'anna@mail.ru',
            'your-message' => 'Нужна сиделка для бабушки',
        ];
        $classification = Classifier::classify($fields, ['reason' => 'Akismet']);
        $result = MessageFormatter::build([
            'fields'         => $fields,
            'classification' => $classification,
            'site_title'     => 'вашаняня.рф',
            'form_title'     => 'Обратная связь',
            'submitted_at'   => '17.04.2026 19:45',
            'reason'         => 'Akismet',
            'ip'             => '185.15.56.12',
            'user_agent'     => 'Mozilla/5.0',
            'is_retro'       => false,
        ]);

        self::assertIsArray($result);
        self::assertArrayHasKey('text', $result);
        self::assertArrayHasKey('reply_markup', $result);

        self::assertStringContainsString('🟢 похоже клиент', $result['text']);
        self::assertStringContainsString('вашаняня.рф', $result['text']);
        self::assertStringContainsString('Анна Петровна', $result['text']);
        self::assertStringContainsString('+7 (903) 123-45-67', $result['text']);
        self::assertStringContainsString('anna@mail.ru', $result['text']);
        self::assertStringContainsString('Akismet', $result['text']);
    }

    public function test_tel_button_present_when_phone_valid(): void
    {
        $fields = ['your-tel' => '+79031234567', 'your-name' => 'Анна'];
        $classification = Classifier::classify($fields, ['reason' => 'Akismet']);
        $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::assertNotNull($r['reply_markup']);
        $json = json_encode($r['reply_markup']);
        self::assertStringContainsString('tel:+79031234567', $json);
    }

    public function test_no_tel_button_when_no_phone(): void
    {
        $fields = ['your-email' => 'a@b.com'];
        $classification = Classifier::classify($fields, ['reason' => 'Akismet']);
        $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::assertNull($r['reply_markup']);
    }

    public function test_html_injection_in_name_is_escaped(): void
    {
        $fields = ['your-name' => '<script>alert(1)</script>', 'your-tel' => '+79031234567'];
        $classification = Classifier::classify($fields, ['reason' => 'Akismet']);
        $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::assertStringNotContainsString('<script>', $r['text']);
        self::assertStringContainsString('&lt;script&gt;', $r['text']);
    }

    public function test_long_message_is_truncated(): void
    {
        $long = str_repeat('А', 5000);
        $fields = ['your-message' => $long, 'your-tel' => '+79031234567'];
        $classification = Classifier::classify($fields, ['reason' => 'Akismet']);
        $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::assertLessThanOrEqual(4096, mb_strlen($r['text']));
        self::assertStringContainsString('…[обрезано]', $r['text']);
    }

    public function test_retro_prefix_added(): void
    {
        $fields = ['your-tel' => '+79031234567'];
        $classification = Classifier::classify($fields, ['reason' => 'Akismet']);
        $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' => true,
        ]);

        self::assertStringContainsString('📂 Ретроспектива', $r['text']);
    }

    public function test_spam_label_emoji(): void
    {
        $fields = [
            'your-name'    => 'Melissa',
            'your-message' => 'SEO promotion, visit http://spam.net',
        ];
        $classification = Classifier::classify($fields, ['reason' => 'Akismet']);
        $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']);
    }

    public function test_unclear_label_emoji(): void
    {
        $fields = ['your-name' => 'Олег', 'your-message' => 'Здравствуйте'];
        $classification = Classifier::classify($fields, ['reason' => 'Akismet']);
        $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']);
    }
}
  • Step 2: Run tests to verify they fail

Run: ./vendor/bin/phpunit --filter MessageFormatterTest Expected: FAIL — class does not exist.


Task 9: MessageFormatter — implementation

Files:

  • Create: includes/class-message-formatter.php

  • Step 1: Implement MessageFormatter::build

<?php
declare(strict_types=1);

namespace Cf7stg;

final class MessageFormatter
{
    private const TG_MAX_LEN = 4096;

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

    /**
     * @param array{
     *   fields:array<string,mixed>,
     *   classification:array{score:int,label:string,reasons:array},
     *   site_title:string,
     *   form_title:string,
     *   submitted_at:string,
     *   reason:string,
     *   ip:string,
     *   user_agent:string,
     *   is_retro:bool
     * } $ctx
     *
     * @return array{text:string,reply_markup:array|null}
     */
    public static function build(array $ctx): array
    {
        $fields = $ctx['fields'];
        $class = $ctx['classification'];

        $phone = PhoneParser::parse(self::flatten_fields_for_phone($fields));
        $name = self::pick($fields, ['your-name', 'name', 'имя', 'fio']);
        $email = self::pick($fields, ['your-email', 'email', 'e-mail', 'mail']);
        $message = self::pick($fields, ['your-message', 'message', 'comment', 'сообщение', 'вопрос']);

        $lines = [];

        $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']) . '»';
        $lines[] = '🗓 ' . esc_html($ctx['submitted_at']);
        $lines[] = '';
        $lines[] = '📱 ' . ($phone !== null ? esc_html($phone['pretty']) : '(не указан)');
        $lines[] = '👤 ' . ($name !== '' ? esc_html($name) : '(не указано)');
        $lines[] = '📧 ' . ($email !== '' ? esc_html($email) : '(не указан)');
        $lines[] = '';
        $lines[] = '💬 ' . ($message !== '' ? esc_html($message) : '(пусто)');
        $lines[] = '';
        $lines[] = '⚙️ Попало в спам: ' . esc_html($ctx['reason']);
        $lines[] = '📊 Классификация: ' . self::format_reasons($class['reasons']);

        $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']));
        if ($meta_bits)                 $lines[] = '🌍 ' . implode(' · ', $meta_bits);

        $utms = self::extract_utms($fields);
        if ($utms !== '')               $lines[] = '🏷 ' . $utms;

        $other = self::extract_other_fields($fields);
        if ($other !== '')              $lines[] = '📋 ' . $other;

        $text = implode("\n", $lines);
        $text = self::truncate($text);

        $markup = null;
        if ($phone !== null) {
            $markup = [
                'inline_keyboard' => [[
                    ['text' => '📞 Позвонить: ' . $phone['pretty'], 'url' => 'tel:' . $phone['e164']],
                ]],
            ];
        }

        return ['text' => $text, 'reply_markup' => $markup];
    }

    private static function pick(array $fields, array $keys): string
    {
        foreach ($keys as $k) {
            if (isset($fields[$k])) {
                $v = $fields[$k];
                if (is_array($v)) $v = implode(' ', $v);
                return trim((string)$v);
            }
        }
        return '';
    }

    private static function flatten_fields_for_phone(array $fields): string
    {
        $out = [];
        foreach ($fields as $k => $v) {
            if (is_array($v)) $v = implode(' ', array_map('strval', $v));
            $out[] = (string)$v;
        }
        return implode("\n", $out);
    }

    private static function format_reasons(array $reasons): string
    {
        if (!$reasons) return '—';
        $parts = [];
        foreach ($reasons as $r) {
            $parts[] = $r['sign'] . $r['weight'] . ' ' . $r['text'];
        }
        return esc_html(implode(', ', $parts));
    }

    private static function shorten_ua(string $ua): string
    {
        $ua = preg_replace('/\s+/', ' ', $ua) ?? $ua;
        if (mb_strlen($ua) > 80) $ua = mb_substr($ua, 0, 80) . '…';
        return $ua;
    }

    private static function extract_utms(array $fields): string
    {
        $keys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
        $parts = [];
        foreach ($keys as $k) {
            if (!empty($fields[$k])) {
                $v = is_array($fields[$k]) ? implode(' ', $fields[$k]) : (string)$fields[$k];
                $parts[] = esc_html($k) . '=' . esc_html($v);
            }
        }
        return implode(' · ', $parts);
    }

    private static function extract_other_fields(array $fields): string
    {
        $known = [
            'your-name', 'name', 'имя', 'fio',
            'your-email', 'email', 'e-mail', 'mail',
            'your-tel', 'tel', 'phone', 'телефон',
            'your-message', 'message', 'comment', 'сообщение', 'вопрос',
            'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
            '_wpcf7', '_wpcf7_version', '_wpcf7_locale', '_wpcf7_unit_tag',
            '_wpcf7_container_post', '_wpcf7_posted_data_hash', 'g-recaptcha-response',
        ];
        $parts = [];
        foreach ($fields as $k => $v) {
            if (in_array($k, $known, 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;
            $parts[] = esc_html($k) . ': ' . esc_html($v);
        }
        return implode(' · ', $parts);
    }

    private static function truncate(string $text): string
    {
        if (mb_strlen($text) <= self::TG_MAX_LEN) return $text;
        $suffix = "\n…[обрезано]";
        $cut = self::TG_MAX_LEN - mb_strlen($suffix);
        return mb_substr($text, 0, $cut) . $suffix;
    }
}
  • Step 2: Run tests to verify pass

Run: ./vendor/bin/phpunit --filter MessageFormatterTest Expected: PASS all 8 assertions.

  • Step 3: Run full test suite to check nothing regressed

Run: ./vendor/bin/phpunit Expected: All 4 test classes pass.

  • Step 4: Commit
git add tests/MessageFormatterTest.php includes/class-message-formatter.php
git commit -m "feat(message-formatter): build Telegram HTML payload with inline tel button"

Task 10: Crypto — token encryption helper

Files:

  • Create: includes/class-crypto.php

No unit tests — relies on AUTH_KEY runtime constant; verified by admin round-trip during pilot.

  • Step 1: Create Crypto class
<?php
declare(strict_types=1);

namespace Cf7stg;

final class Crypto
{
    private const CIPHER = 'AES-256-CBC';

    public static function encrypt(string $plaintext): string
    {
        if ($plaintext === '') return '';
        $key = self::key();
        $iv = random_bytes(16);
        $enc = openssl_encrypt($plaintext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv);
        if ($enc === false) return '';
        return base64_encode($iv . $enc);
    }

    public static function decrypt(string $encoded): ?string
    {
        if ($encoded === '') return '';
        $raw = base64_decode($encoded, true);
        if ($raw === false || strlen($raw) < 17) return null;
        $iv = substr($raw, 0, 16);
        $enc = substr($raw, 16);
        $plain = openssl_decrypt($enc, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv);
        return $plain === false ? null : $plain;
    }

    private static function key(): string
    {
        $seed = defined('AUTH_KEY') ? AUTH_KEY : 'cf7stg-fallback-seed';
        return hash_hmac('sha256', 'cf7stg_token', $seed, true);
    }
}
  • Step 2: Commit
git add includes/class-crypto.php
git commit -m "feat(crypto): AES-256-CBC token encryption via AUTH_KEY"

Task 11: Settings — options wrapper

Files:

  • Create: includes/class-settings.php

  • Step 1: Create Settings class

<?php
declare(strict_types=1);

namespace Cf7stg;

final class Settings
{
    public const OPT_BOT_TOKEN   = 'cf7stg_bot_token_enc';
    public const OPT_CHAT_ID     = 'cf7stg_chat_id';
    public const OPT_SITE_TITLE  = 'cf7stg_site_title';
    public const OPT_LAST_TEST   = 'cf7stg_last_test';

    public static function get_bot_token(): string
    {
        if (defined('SPAM2TG_BOT_TOKEN') && SPAM2TG_BOT_TOKEN !== '') {
            return (string)SPAM2TG_BOT_TOKEN;
        }
        $enc = (string)get_option(self::OPT_BOT_TOKEN, '');
        $dec = Crypto::decrypt($enc);
        return $dec ?? '';
    }

    public static function is_bot_token_constant(): bool
    {
        return defined('SPAM2TG_BOT_TOKEN') && SPAM2TG_BOT_TOKEN !== '';
    }

    public static function set_bot_token(string $plaintext): void
    {
        update_option(self::OPT_BOT_TOKEN, Crypto::encrypt($plaintext));
    }

    public static function get_chat_id(): string
    {
        if (defined('SPAM2TG_CHAT_ID') && SPAM2TG_CHAT_ID !== '') {
            return (string)SPAM2TG_CHAT_ID;
        }
        return (string)get_option(self::OPT_CHAT_ID, '');
    }

    public static function is_chat_id_constant(): bool
    {
        return defined('SPAM2TG_CHAT_ID') && SPAM2TG_CHAT_ID !== '';
    }

    public static function set_chat_id(string $chat_id): void
    {
        update_option(self::OPT_CHAT_ID, $chat_id);
    }

    public static function get_site_title(): string
    {
        $override = trim((string)get_option(self::OPT_SITE_TITLE, ''));
        if ($override !== '') return $override;
        $host = parse_url(home_url(), PHP_URL_HOST) ?: '';
        return DomainFormatter::humanize((string)$host);
    }

    public static function set_site_title(string $title): void
    {
        update_option(self::OPT_SITE_TITLE, trim($title));
    }

    public static function get_last_test(): array
    {
        $v = get_option(self::OPT_LAST_TEST, []);
        return is_array($v) ? $v : [];
    }

    public static function set_last_test(array $v): void
    {
        update_option(self::OPT_LAST_TEST, $v);
    }

    public static function is_configured(): bool
    {
        return self::get_bot_token() !== '' && self::get_chat_id() !== '';
    }
}
  • Step 2: Commit
git add includes/class-settings.php
git commit -m "feat(settings): options wrapper with wp-config constant override"

Task 12: Queue — CRUD + dedup

Files:

  • Create: includes/class-queue.php

  • Step 1: Create Queue class

<?php
declare(strict_types=1);

namespace Cf7stg;

final class Queue
{
    public const STATUS_PENDING = 'pending';
    public const STATUS_SENT    = 'sent';
    public const STATUS_FAILED  = 'failed';

    public static function table_name(): string
    {
        global $wpdb;
        return $wpdb->prefix . 'cf7stg_queue';
    }

    public static function enqueue(array $row): int
    {
        global $wpdb;
        $defaults = [
            'flamingo_post_id' => null,
            'form_id'          => null,
            'source_key'       => 'cf7',
            'payload_json'     => '',
            'label'            => 'unclear',
            'is_retro'         => 0,
            'status'           => self::STATUS_PENDING,
            'attempts'         => 0,
            'last_error'       => null,
            'next_try_at'      => current_time('mysql'),
            'created_at'       => current_time('mysql'),
            'sent_at'          => null,
        ];
        $data = array_merge($defaults, $row);
        $wpdb->insert(self::table_name(), $data);
        return (int)$wpdb->insert_id;
    }

    public static function exists_for_flamingo(int $post_id, bool $is_retro): bool
    {
        global $wpdb;
        $t = self::table_name();
        $sql = $wpdb->prepare(
            "SELECT id FROM {$t} WHERE flamingo_post_id = %d AND is_retro = %d LIMIT 1",
            $post_id,
            $is_retro ? 1 : 0
        );
        return (bool)$wpdb->get_var($sql);
    }

    /** @return array<int,object> */
    public static function fetch_pending(int $limit = 5): array
    {
        global $wpdb;
        $t = self::table_name();
        $sql = $wpdb->prepare(
            "SELECT * FROM {$t}
             WHERE status = %s
               AND (next_try_at IS NULL OR next_try_at <= %s)
             ORDER BY id ASC
             LIMIT %d",
            self::STATUS_PENDING,
            current_time('mysql'),
            $limit
        );
        $rows = $wpdb->get_results($sql);
        return is_array($rows) ? $rows : [];
    }

    public static function mark_sent(int $id): void
    {
        global $wpdb;
        $wpdb->update(
            self::table_name(),
            ['status' => self::STATUS_SENT, 'sent_at' => current_time('mysql'), 'last_error' => null],
            ['id' => $id]
        );
    }

    public static function reschedule(int $id, int $attempts, string $error, int $backoff_seconds): void
    {
        global $wpdb;
        $next = gmdate('Y-m-d H:i:s', time() + $backoff_seconds);
        $wpdb->update(
            self::table_name(),
            [
                'attempts'    => $attempts,
                'last_error'  => $error,
                'next_try_at' => $next,
            ],
            ['id' => $id]
        );
    }

    public static function mark_failed(int $id, string $error, int $attempts): void
    {
        global $wpdb;
        $wpdb->update(
            self::table_name(),
            [
                'status'     => self::STATUS_FAILED,
                'last_error' => $error,
                'attempts'   => $attempts,
            ],
            ['id' => $id]
        );
    }

    public static function count_by_status(string $status): int
    {
        global $wpdb;
        $t = self::table_name();
        return (int)$wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM {$t} WHERE status = %s", $status
        ));
    }

    public static function count_failed(): int
    {
        return self::count_by_status(self::STATUS_FAILED);
    }

    public static function count_sent_last_24h(): int
    {
        global $wpdb;
        $t = self::table_name();
        $cutoff = gmdate('Y-m-d H:i:s', time() - 86400);
        return (int)$wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM {$t} WHERE status = %s AND sent_at >= %s",
            self::STATUS_SENT, $cutoff
        ));
    }

    /** @return array<int,object> */
    public static function list_failed(int $limit = 10): array
    {
        global $wpdb;
        $t = self::table_name();
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT id, created_at, last_error FROM {$t}
             WHERE status = %s
             ORDER BY id DESC LIMIT %d",
            self::STATUS_FAILED, $limit
        ));
        return is_array($rows) ? $rows : [];
    }

    public static function retry_all_failed(): int
    {
        global $wpdb;
        $t = self::table_name();
        $now = current_time('mysql');
        $count = $wpdb->query($wpdb->prepare(
            "UPDATE {$t}
             SET status = %s, attempts = 0, last_error = NULL, next_try_at = %s
             WHERE status = %s",
            self::STATUS_PENDING, $now, self::STATUS_FAILED
        ));
        return (int)$count;
    }

    public static function purge_sent(): int
    {
        global $wpdb;
        $t = self::table_name();
        return (int)$wpdb->query($wpdb->prepare(
            "DELETE FROM {$t} WHERE status = %s", self::STATUS_SENT
        ));
    }

    public static function cleanup_old_sent(int $days = 30): int
    {
        global $wpdb;
        $t = self::table_name();
        $cutoff = gmdate('Y-m-d H:i:s', time() - $days * 86400);
        return (int)$wpdb->query($wpdb->prepare(
            "DELETE FROM {$t} WHERE status = %s AND sent_at < %s",
            self::STATUS_SENT, $cutoff
        ));
    }
}
  • Step 2: Commit
git add includes/class-queue.php
git commit -m "feat(queue): CRUD, dedup, retention, status counters"

Task 13: TelegramException

Files:

  • Create: includes/class-telegram-exception.php

  • Step 1: Create exception class

<?php
declare(strict_types=1);

namespace Cf7stg;

final class TelegramException extends \Exception
{
    private bool $permanent;
    private int $retry_after;

    public function __construct(string $message, int $code = 0, bool $permanent = false, int $retry_after = 0)
    {
        parent::__construct($message, $code);
        $this->permanent = $permanent;
        $this->retry_after = max(0, $retry_after);
    }

    public function is_permanent(): bool
    {
        return $this->permanent;
    }

    public function retry_after(): int
    {
        return $this->retry_after;
    }
}
  • Step 2: Commit
git add includes/class-telegram-exception.php
git commit -m "feat(telegram): exception carrying permanent/retry_after flags"

Task 14: TelegramClient

Files:

  • Create: includes/class-telegram-client.php

  • Step 1: Create TelegramClient

<?php
declare(strict_types=1);

namespace Cf7stg;

final class TelegramClient
{
    /**
     * Send a message. Throws TelegramException on error.
     *
     * @param string|int $chat_id
     * @param string     $text        Pre-escaped HTML
     * @param array|null $reply_markup Optional inline_keyboard structure
     */
    public static function send_message($chat_id, string $text, ?array $reply_markup = null): array
    {
        $token = Settings::get_bot_token();
        if ($token === '' || $chat_id === '' || $chat_id === null) {
            throw new TelegramException('Bot token or chat_id not configured', 0, true);
        }

        $body = [
            'chat_id'    => $chat_id,
            'text'       => $text,
            'parse_mode' => 'HTML',
            'disable_web_page_preview' => true,
        ];
        if ($reply_markup !== null) {
            $body['reply_markup'] = wp_json_encode($reply_markup);
        }

        $url = 'https://api.telegram.org/bot' . $token . '/sendMessage';
        $response = wp_remote_post($url, [
            'timeout' => 10,
            'body'    => $body,
        ]);

        if (is_wp_error($response)) {
            throw new TelegramException('Network: ' . $response->get_error_message(), 0, false);
        }

        $code = (int)wp_remote_retrieve_response_code($response);
        $raw = (string)wp_remote_retrieve_body($response);
        $data = json_decode($raw, true);

        if ($code === 200 && is_array($data) && !empty($data['ok'])) {
            return $data;
        }

        $desc = is_array($data) ? (string)($data['description'] ?? '') : '';
        $retry_after = 0;
        if (is_array($data) && isset($data['parameters']['retry_after'])) {
            $retry_after = (int)$data['parameters']['retry_after'];
        }

        $permanent = self::is_permanent($code, $desc);
        throw new TelegramException(
            sprintf('HTTP %d %s', $code, $desc !== '' ? $desc : 'unknown error'),
            $code,
            $permanent,
            $retry_after
        );
    }

    private static function is_permanent(int $code, string $desc): bool
    {
        if ($code === 401 || $code === 403) return true;
        if ($code === 400) {
            $d = strtolower($desc);
            if (strpos($d, 'chat not found') !== false) return true;
            if (strpos($d, 'bot was blocked') !== false) return true;
            if (strpos($d, 'user is deactivated') !== false) return true;
        }
        return false;
    }
}
  • Step 2: Commit
git add includes/class-telegram-client.php
git commit -m "feat(telegram-client): sendMessage wrapper classifying permanent/transient errors"

Task 15: Dispatcher — WP-Cron worker

Files:

  • Create: includes/class-dispatcher.php

  • Step 1: Create Dispatcher

<?php
declare(strict_types=1);

namespace Cf7stg;

final class Dispatcher
{
    private const BACKOFF_SECONDS = [60, 300, 1800, 3600, 3600];
    private const MAX_ATTEMPTS = 5;

    public static function register_hooks(): void
    {
        add_filter('cron_schedules', [__CLASS__, 'register_schedule']);
        add_action('cf7stg_dispatch', [__CLASS__, 'tick']);
        add_action('cf7stg_cleanup', [__CLASS__, 'cleanup']);
    }

    public static function register_schedule(array $schedules): array
    {
        $schedules['cf7stg_every_minute'] = [
            'interval' => 60,
            'display'  => 'Раз в минуту (CF7 Spam → TG)',
        ];
        return $schedules;
    }

    public static function tick(): void
    {
        if (!Settings::is_configured()) {
            return;
        }
        $items = Queue::fetch_pending(5);
        $chat_id = Settings::get_chat_id();
        foreach ($items as $item) {
            $payload = json_decode((string)$item->payload_json, true);
            if (!is_array($payload) || empty($payload['text'])) {
                Queue::mark_failed((int)$item->id, 'Invalid payload JSON', (int)$item->attempts + 1);
                continue;
            }
            try {
                TelegramClient::send_message(
                    $chat_id,
                    (string)$payload['text'],
                    isset($payload['reply_markup']) && is_array($payload['reply_markup']) ? $payload['reply_markup'] : null
                );
                Queue::mark_sent((int)$item->id);
            } catch (TelegramException $e) {
                self::handle_failure($item, $e);
            }
            usleep(100000); // 100ms between sends
        }
    }

    private static function handle_failure(object $item, TelegramException $e): void
    {
        $attempts = (int)$item->attempts + 1;
        if ($e->is_permanent() || $attempts >= self::MAX_ATTEMPTS) {
            Queue::mark_failed((int)$item->id, $e->getMessage(), $attempts);
            return;
        }
        $backoff = self::BACKOFF_SECONDS[$attempts - 1] ?? end(self::BACKOFF_SECONDS);
        if ($e->retry_after() > $backoff) {
            $backoff = $e->retry_after();
        }
        Queue::reschedule((int)$item->id, $attempts, $e->getMessage(), $backoff);
    }

    public static function cleanup(): void
    {
        Queue::cleanup_old_sent(30);
    }
}
  • Step 2: Commit
git add includes/class-dispatcher.php
git commit -m "feat(dispatcher): WP-Cron tick with backoff and retention cleanup"

Task 16: FlamingoHelper

Files:

  • Create: includes/class-flamingo-helper.php

  • Step 1: Create FlamingoHelper

<?php
declare(strict_types=1);

namespace Cf7stg;

final class FlamingoHelper
{
    /**
     * Try to find the Flamingo post created by the given CF7 submission.
     * Matches by recency (created within the last 30 seconds) and a content digest.
     */
    public static function find_post_id_for_submission($submission): ?int
    {
        if (!class_exists('\\WPCF7_Submission') && !is_object($submission)) {
            return null;
        }
        $posts = get_posts([
            'post_type'      => 'flamingo_inbound',
            'post_status'    => ['flamingo-spam', 'flamingo-inbound', 'publish'],
            'posts_per_page' => 5,
            'orderby'        => 'date',
            'order'          => 'DESC',
            'date_query'     => [[
                'after' => gmdate('Y-m-d H:i:s', time() - 30),
                'inclusive' => true,
            ]],
        ]);
        if (!$posts) return null;

        // Prefer the first (most recent) — Flamingo writes one row per submission.
        return (int)$posts[0]->ID;
    }

    /**
     * Extract fields from a Flamingo inbound post.
     *
     * @return array<string,mixed>
     */
    public static function extract_fields(\WP_Post $post): array
    {
        $fields = get_post_meta($post->ID, '_fields', true);
        if (is_array($fields)) return $fields;
        // Fallback: meta stored under Flamingo's structure
        $meta = get_post_meta($post->ID);
        $out = [];
        foreach ($meta as $key => $values) {
            if (strpos($key, '_field_') === 0) {
                $out[substr($key, strlen('_field_'))] = maybe_unserialize($values[0] ?? '');
            }
        }
        return $out;
    }

    public static function extract_reason(\WP_Post $post): string
    {
        $log = (string)get_post_meta($post->ID, '_akismet', true);
        if ($log !== '') return 'Akismet';
        return 'Неизвестно';
    }
}
  • Step 2: Commit
git add includes/class-flamingo-helper.php
git commit -m "feat(flamingo-helper): locate flamingo_inbound posts and extract fields/reason"

Task 17: Source interface + CF7Source

Files:

  • Create: includes/sources/interface-spam-source.php

  • Create: includes/sources/class-cf7-source.php

  • Step 1: Create the interface

<?php
declare(strict_types=1);

namespace Cf7stg;

interface Interface_Spam_Source
{
    public function register(): void;
}
  • Step 2: Create CF7Source
<?php
declare(strict_types=1);

namespace Cf7stg;

final class CF7Source implements Interface_Spam_Source
{
    public function register(): void
    {
        add_filter('wpcf7_spam', [$this, 'on_spam'], 9999, 2);
        add_action('wpcf7_submission', [$this, 'on_submission'], 20, 1);
    }

    public function on_spam($spam, $submission): bool
    {
        if ($spam === true && is_object($submission)) {
            $this->enqueue_from_submission($submission, 'Honeypot or filter');
        }
        return (bool)$spam;
    }

    public function on_submission($submission): void
    {
        if (!is_object($submission) || !method_exists($submission, 'get_status')) return;
        if ($submission->get_status() !== 'spam') return;
        $this->enqueue_from_submission($submission, 'Статус spam');
    }

    private function enqueue_from_submission($submission, string $fallback_reason): void
    {
        $fields = method_exists($submission, 'get_posted_data') ? (array)$submission->get_posted_data() : [];
        $form = method_exists($submission, 'get_contact_form') ? $submission->get_contact_form() : null;
        $form_title = $form && method_exists($form, 'title') ? $form->title() : '';
        $form_id = $form && method_exists($form, 'id') ? (int)$form->id() : null;

        $reason = $this->detect_reason($submission, $fallback_reason);
        $classification = Classifier::classify($fields, ['reason' => $reason]);

        $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,
        ]);

        Queue::enqueue([
            'flamingo_post_id' => $post_id,
            'form_id'          => $form_id,
            'source_key'       => 'cf7',
            'payload_json'     => wp_json_encode($payload),
            'label'            => $classification['label'],
            'is_retro'         => 0,
        ]);
    }

    private function detect_reason($submission, string $fallback): string
    {
        $data = method_exists($submission, 'get_posted_data') ? (array)$submission->get_posted_data() : [];
        // Honeypot plugin by Nocean adds a non-empty field usually prefixed by 'wpcf7_hp' or a custom name.
        foreach ($data as $k => $v) {
            if (is_string($k) && (stripos($k, 'honeypot') !== false || stripos($k, 'wpcf7_hp') !== false) && !empty($v)) {
                return 'Honeypot';
            }
        }
        if (method_exists($submission, 'get_spam_log') && $submission->get_spam_log()) {
            return 'Akismet';
        }
        return $fallback;
    }

    private function remote_addr(): string
    {
        foreach (['HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $key) {
            if (!empty($_SERVER[$key])) {
                $first = explode(',', (string)$_SERVER[$key])[0];
                return trim($first);
            }
        }
        return '';
    }
}
  • Step 3: Commit
git add includes/sources/interface-spam-source.php includes/sources/class-cf7-source.php
git commit -m "feat(cf7-source): capture spam submissions via wpcf7_spam and submission status"

Task 18: RetroImporter

Files:

  • Create: includes/class-retro-importer.php

  • Step 1: Create RetroImporter

<?php
declare(strict_types=1);

namespace Cf7stg;

final class RetroImporter
{
    public const MAX_COUNT = 50;

    /**
     * @return array{added:int,skipped:int}
     */
    public static function import(int $count, int $days, bool $allow_repeat): array
    {
        $count = max(1, min(self::MAX_COUNT, $count));
        $args = [
            'post_type'      => 'flamingo_inbound',
            'post_status'    => 'flamingo-spam',
            'orderby'        => 'date',
            'order'          => 'DESC',
            'posts_per_page' => $count,
        ];
        if ($days > 0) {
            $args['date_query'] = [[
                'after' => gmdate('Y-m-d H:i:s', time() - $days * 86400),
                'inclusive' => true,
            ]];
        }
        $posts = get_posts($args);

        $added = 0; $skipped = 0;
        foreach ($posts as $post) {
            if (!$allow_repeat && Queue::exists_for_flamingo((int)$post->ID, true)) {
                $skipped++;
                continue;
            }
            $fields = FlamingoHelper::extract_fields($post);
            $reason = FlamingoHelper::extract_reason($post);
            $classification = Classifier::classify($fields, ['reason' => $reason]);

            $payload = MessageFormatter::build([
                'fields'         => $fields,
                'classification' => $classification,
                'site_title'     => Settings::get_site_title(),
                'form_title'     => (string)get_post_meta($post->ID, '_subject', true),
                'submitted_at'   => wp_date('d.m.Y H:i', (int)get_post_time('U', true, $post)),
                'reason'         => $reason,
                'ip'             => (string)get_post_meta($post->ID, '_meta_remote_ip', true),
                'user_agent'     => (string)get_post_meta($post->ID, '_meta_user_agent', true),
                'is_retro'       => true,
            ]);

            Queue::enqueue([
                'flamingo_post_id' => (int)$post->ID,
                'form_id'          => null,
                'source_key'       => 'cf7',
                'payload_json'     => wp_json_encode($payload),
                'label'            => $classification['label'],
                'is_retro'         => 1,
            ]);
            $added++;
        }

        return compact('added', 'skipped');
    }
}
  • Step 2: Commit
git add includes/class-retro-importer.php
git commit -m "feat(retro-importer): queue last-N Flamingo spam posts with dedup"

Task 19: Activator

Files:

  • Create: includes/class-activator.php

  • Step 1: Create Activator

<?php
declare(strict_types=1);

namespace Cf7stg;

final class Activator
{
    public const DB_VERSION = '1';
    public const DB_VERSION_OPTION = 'cf7stg_db_version';

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

    public static function deactivate(): void
    {
        wp_clear_scheduled_hook('cf7stg_dispatch');
        wp_clear_scheduled_hook('cf7stg_cleanup');
    }

    private static function guard_requirements(): void
    {
        if (version_compare(PHP_VERSION, '7.4', '<')) {
            deactivate_plugins(plugin_basename(CF7STG_PLUGIN_FILE));
            wp_die('Плагин CF7 Spam → Telegram требует PHP 7.4 или выше. Текущая: ' . PHP_VERSION);
        }
        global $wp_version;
        if (version_compare($wp_version, '6.0', '<')) {
            deactivate_plugins(plugin_basename(CF7STG_PLUGIN_FILE));
            wp_die('Плагин CF7 Spam → Telegram требует WordPress 6.0 или выше.');
        }
    }

    private static function install_table(): void
    {
        global $wpdb;
        $table = $wpdb->prefix . 'cf7stg_queue';
        $charset_collate = $wpdb->get_charset_collate();

        $sql = "CREATE TABLE {$table} (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            flamingo_post_id BIGINT UNSIGNED NULL,
            form_id BIGINT UNSIGNED NULL,
            source_key VARCHAR(40) NOT NULL DEFAULT 'cf7',
            payload_json LONGTEXT NOT NULL,
            label VARCHAR(10) NOT NULL DEFAULT 'unclear',
            is_retro TINYINT(1) NOT NULL DEFAULT 0,
            status VARCHAR(20) NOT NULL DEFAULT 'pending',
            attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,
            last_error TEXT NULL,
            next_try_at DATETIME NULL,
            created_at DATETIME NOT NULL,
            sent_at DATETIME NULL,
            PRIMARY KEY  (id),
            KEY idx_status_next (status, next_try_at),
            UNIQUE KEY uq_flamingo (flamingo_post_id, is_retro)
        ) {$charset_collate};";

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta($sql);
        update_option(self::DB_VERSION_OPTION, self::DB_VERSION);
    }

    private static function schedule_events(): void
    {
        // Register the custom 60-second schedule here — Dispatcher::register_hooks()
        // runs on plugins_loaded, which is AFTER activation. Without this filter
        // being in place during activate(), wp_schedule_event with an unknown
        // recurrence ('cf7stg_every_minute') silently fails and the event never
        // fires.
        add_filter('cron_schedules', ['\\Cf7stg\\Dispatcher', 'register_schedule']);

        if (!wp_next_scheduled('cf7stg_dispatch')) {
            wp_schedule_event(time() + 60, 'cf7stg_every_minute', 'cf7stg_dispatch');
        }
        if (!wp_next_scheduled('cf7stg_cleanup')) {
            wp_schedule_event(time() + 3600, 'daily', 'cf7stg_cleanup');
        }
    }
}
  • Step 2: Commit
git add includes/class-activator.php
git commit -m "feat(activator): create queue table, schedule cron, guard PHP/WP versions"

Task 20: Plugin bootstrap + main file

Files:

  • Create: includes/class-plugin.php

  • Create: cf7-spam-to-telegram.php

  • Step 1: Create includes/class-plugin.php

<?php
declare(strict_types=1);

namespace Cf7stg;

final class Plugin
{
    private static ?Plugin $instance = null;

    public static function instance(): Plugin
    {
        if (self::$instance === null) self::$instance = new self();
        return self::$instance;
    }

    public static function register_autoloader(): void
    {
        spl_autoload_register(static function (string $class): void {
            if (strpos($class, 'Cf7stg\\') !== 0) return;
            $relative = substr($class, strlen('Cf7stg\\'));
            $slug = strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1-$2', $relative));
            // Class names may use underscores (Settings_Page, Interface_Spam_Source);
            // normalize both word-boundary conventions to hyphens.
            $slug = str_replace('_', '-', $slug);
            $candidates = [
                dirname(__DIR__) . '/includes/class-' . $slug . '.php',
                dirname(__DIR__) . '/includes/sources/class-' . $slug . '.php',
                dirname(__DIR__) . '/admin/class-' . $slug . '.php',
                // Fallback: treat slug as full filename (for Interface_* classes
                // whose slug already becomes 'interface-xxx').
                dirname(__DIR__) . '/includes/' . $slug . '.php',
                dirname(__DIR__) . '/includes/sources/' . $slug . '.php',
                dirname(__DIR__) . '/admin/' . $slug . '.php',
            ];
            foreach ($candidates as $path) {
                if (is_file($path)) {
                    require_once $path;
                    return;
                }
            }
        });
    }

    public function boot(): void
    {
        // Register runtime hooks
        Dispatcher::register_hooks();
        (new CF7Source())->register();

        // Admin
        if (is_admin()) {
            (new Settings_Page())->register();
            (new Dashboard_Widget())->register();
        }
    }
}
  • Step 2: Create cf7-spam-to-telegram.php
<?php
/**
 * Plugin Name: CF7 Spam → Telegram
 * Description: Пересылает spam-заявки Contact Form 7 в Telegram-группу с эвристической оценкой.
 * Version: 1.0.0
 * Author: Vladimir Bryzgalov
 * Requires PHP: 7.4
 * Requires at least: 6.0
 * License: GPLv2 or later
 * Text Domain: cf7-spam-to-telegram
 */

declare(strict_types=1);

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.0');

require_once CF7STG_PLUGIN_DIR . 'includes/class-plugin.php';
\Cf7stg\Plugin::register_autoloader();

register_activation_hook(__FILE__, ['\\Cf7stg\\Activator', 'activate']);
register_deactivation_hook(__FILE__, ['\\Cf7stg\\Activator', 'deactivate']);

add_action('plugins_loaded', static function (): void {
    \Cf7stg\Plugin::instance()->boot();
});
  • Step 3: Smoke-check the main file parses

Run: php -l cf7-spam-to-telegram.php includes/class-plugin.php Expected: No syntax errors detected for both.

  • Step 4: Commit
git add cf7-spam-to-telegram.php includes/class-plugin.php
git commit -m "feat: plugin bootstrap, autoloader, activation/deactivation hooks"

Task 21: Settings_Page (admin UI)

Files:

  • Create: admin/class-settings-page.php

  • Create: admin/views/settings.php

  • Step 1: Create admin/class-settings-page.php

<?php
declare(strict_types=1);

namespace Cf7stg;

final class Settings_Page
{
    private const PAGE_SLUG = 'cf7stg-settings';
    private const NONCE = 'cf7stg_settings_nonce';

    public function register(): void
    {
        add_action('admin_menu', [$this, 'add_menu']);
        add_action('admin_post_cf7stg_save', [$this, 'handle_save']);
        add_action('admin_post_cf7stg_test', [$this, 'handle_test']);
        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']);
    }

    public function add_menu(): void
    {
        add_options_page(
            'CF7 Spam → Telegram',
            'CF7 Spam → TG',
            'manage_options',
            self::PAGE_SLUG,
            [$this, 'render']
        );
    }

    public function render(): void
    {
        if (!current_user_can('manage_options')) return;
        $page_slug = self::PAGE_SLUG;
        $nonce = self::NONCE;
        include CF7STG_PLUGIN_DIR . 'admin/views/settings.php';
    }

    public function handle_save(): void
    {
        $this->guard('cf7stg_save');
        if (!Settings::is_bot_token_constant()) {
            $token = (string)($_POST['bot_token'] ?? '');
            if ($token !== '') Settings::set_bot_token($token);
        }
        if (!Settings::is_chat_id_constant()) {
            Settings::set_chat_id(sanitize_text_field((string)($_POST['chat_id'] ?? '')));
        }
        Settings::set_site_title(sanitize_text_field((string)($_POST['site_title'] ?? '')));
        $this->redirect_back(['saved' => 1]);
    }

    public function handle_test(): void
    {
        $this->guard('cf7stg_test');
        try {
            TelegramClient::send_message(
                Settings::get_chat_id(),
                '✅ Тестовое сообщение из плагина CF7 Spam → Telegram ('
                    . esc_html(Settings::get_site_title()) . ')'
            );
            Settings::set_last_test(['ok' => true, 'at' => current_time('mysql'), 'err' => '']);
            $this->redirect_back(['test' => 'ok']);
        } catch (TelegramException $e) {
            Settings::set_last_test(['ok' => false, 'at' => current_time('mysql'), 'err' => $e->getMessage()]);
            $this->redirect_back(['test' => 'err']);
        }
    }

    public function handle_retro(): void
    {
        $this->guard('cf7stg_retro');
        $count = max(1, min(RetroImporter::MAX_COUNT, (int)($_POST['retro_count'] ?? 10)));
        $days  = max(0, (int)($_POST['retro_days'] ?? 7));
        $repeat = !empty($_POST['retro_repeat']);
        $result = RetroImporter::import($count, $days, $repeat);
        $this->redirect_back(['retro_added' => (int)$result['added'], 'retro_skipped' => (int)$result['skipped']]);
    }

    public function handle_retry_failed(): void
    {
        $this->guard('cf7stg_retry_failed');
        $n = Queue::retry_all_failed();
        $this->redirect_back(['retried' => $n]);
    }

    public function handle_purge_sent(): void
    {
        $this->guard('cf7stg_purge_sent');
        $n = Queue::purge_sent();
        $this->redirect_back(['purged' => $n]);
    }

    private function guard(string $action): void
    {
        if (!current_user_can('manage_options')) wp_die('forbidden');
        check_admin_referer($action, self::NONCE);
    }

    private function redirect_back(array $extra = []): void
    {
        $url = add_query_arg(array_merge(['page' => self::PAGE_SLUG], $extra), admin_url('options-general.php'));
        wp_safe_redirect($url);
        exit;
    }
}
  • Step 2: Create admin/views/settings.php
<?php
/** @var string $page_slug */
/** @var string $nonce */
$last_test = \Cf7stg\Settings::get_last_test();
$pending = \Cf7stg\Queue::count_by_status(\Cf7stg\Queue::STATUS_PENDING);
$sent24 = \Cf7stg\Queue::count_sent_last_24h();
$failed = \Cf7stg\Queue::count_failed();

$notice = '';
if (!empty($_GET['saved']))        $notice = '<div class="notice notice-success"><p>Настройки сохранены.</p></div>';
if (($_GET['test'] ?? '') === 'ok')  $notice = '<div class="notice notice-success"><p>Тестовое сообщение отправлено.</p></div>';
if (($_GET['test'] ?? '') === 'err') $notice = '<div class="notice notice-error"><p>Ошибка отправки: ' . esc_html($last_test['err'] ?? '') . '</p></div>';
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>';
?>
<div class="wrap">
    <h1>CF7 Spam → Telegram</h1>
    <?php echo $notice; ?>

    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
        <input type="hidden" name="action" value="cf7stg_save">
        <?php wp_nonce_field('cf7stg_save', $nonce); ?>
        <h2>Настройки Telegram</h2>
        <table class="form-table">
            <tr>
                <th><label for="bot_token">Bot Token</label></th>
                <td>
                    <?php if (\Cf7stg\Settings::is_bot_token_constant()): ?>
                        <em>Задано константой <code>SPAM2TG_BOT_TOKEN</code> в wp-config.php (readonly).</em>
                    <?php else: ?>
                        <input type="password" id="bot_token" name="bot_token" class="regular-text" autocomplete="off" placeholder="<?php echo \Cf7stg\Settings::get_bot_token() ? '(сохранён)' : ''; ?>">
                        <p class="description">Можно задать константой <code>SPAM2TG_BOT_TOKEN</code> в wp-config.php вместо БД.</p>
                    <?php endif; ?>
                </td>
            </tr>
            <tr>
                <th><label for="chat_id">Chat ID</label></th>
                <td>
                    <?php if (\Cf7stg\Settings::is_chat_id_constant()): ?>
                        <em>Задано константой <code>SPAM2TG_CHAT_ID</code> (readonly).</em>
                    <?php else: ?>
                        <input type="text" id="chat_id" name="chat_id" class="regular-text" value="<?php echo esc_attr(\Cf7stg\Settings::get_chat_id()); ?>">
                    <?php endif; ?>
                </td>
            </tr>
            <tr>
                <th><label for="site_title">Заголовок сайта</label></th>
                <td>
                    <input type="text" id="site_title" name="site_title" class="regular-text" value="<?php echo esc_attr((string)get_option(\Cf7stg\Settings::OPT_SITE_TITLE, '')); ?>" placeholder="<?php echo esc_attr(\Cf7stg\Settings::get_site_title()); ?>">
                    <p class="description">Если пусто — используется домен (декодированный из Punycode).</p>
                </td>
            </tr>
        </table>
        <?php submit_button('Сохранить настройки'); ?>
    </form>

    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="margin-top:16px">
        <input type="hidden" name="action" value="cf7stg_test">
        <?php wp_nonce_field('cf7stg_test', $nonce); ?>
        <button type="submit" class="button">Отправить тестовое сообщение</button>
        <?php if (!empty($last_test)): ?>
            <span style="margin-left:12px">
                <?php if (!empty($last_test['ok'])): ?>
                    ✅ Последняя проверка: <?php echo esc_html((string)$last_test['at']); ?> — OK
                <?php else: ?>
                    ⚠️ Последняя проверка: <?php echo esc_html((string)$last_test['at']); ?> — <?php echo esc_html((string)$last_test['err']); ?>
                <?php endif; ?>
            </span>
        <?php endif; ?>
    </form>

    <hr>

    <h2>Ретроспективная выгрузка из Flamingo</h2>
    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
        <input type="hidden" name="action" value="cf7stg_retro">
        <?php wp_nonce_field('cf7stg_retro', $nonce); ?>
        <table class="form-table">
            <tr>
                <th>Количество записей</th>
                <td><input type="number" name="retro_count" value="10" min="1" max="50"> <span class="description">макс. 50</span></td>
            </tr>
            <tr>
                <th>Только за последние</th>
                <td><input type="number" name="retro_days" value="7" min="0"> дней <span class="description">(0 = без ограничения)</span></td>
            </tr>
            <tr>
                <th>&nbsp;</th>
                <td><label><input type="checkbox" name="retro_repeat" value="1"> Разрешить повтор уже отправленных</label></td>
            </tr>
        </table>
        <?php submit_button('Добавить в очередь', 'secondary'); ?>
    </form>

    <hr>

    <h2>Состояние очереди</h2>
    <p>В очереди (ожидают): <b><?php echo (int)$pending; ?></b> · Отправлено за сутки: <b><?php echo (int)$sent24; ?></b> · С ошибками: <b><?php echo (int)$failed; ?></b></p>

    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline-block;margin-right:8px">
        <input type="hidden" name="action" value="cf7stg_retry_failed">
        <?php wp_nonce_field('cf7stg_retry_failed', $nonce); ?>
        <button type="submit" class="button">Повторить все ошибочные</button>
    </form>
    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline-block">
        <input type="hidden" name="action" value="cf7stg_purge_sent">
        <?php wp_nonce_field('cf7stg_purge_sent', $nonce); ?>
        <button type="submit" class="button">Очистить отправленные</button>
    </form>
</div>
  • Step 3: Commit
git add admin/class-settings-page.php admin/views/settings.php
git commit -m "feat(admin): settings page with token/chat/site-title, test button, retro, queue state"

Task 22: Dashboard_Widget

Files:

  • Create: admin/class-dashboard-widget.php

  • Create: admin/views/dashboard-widget.php

  • Step 1: Create admin/class-dashboard-widget.php

<?php
declare(strict_types=1);

namespace Cf7stg;

final class Dashboard_Widget
{
    public function register(): void
    {
        add_action('wp_dashboard_setup', [$this, 'maybe_register_widget']);
    }

    public function maybe_register_widget(): void
    {
        if (Queue::count_failed() === 0) return;
        wp_add_dashboard_widget(
            'cf7stg_dashboard_errors',
            '⚠️ CF7 Spam → Telegram: ошибки доставки',
            [$this, 'render']
        );
    }

    public function render(): void
    {
        $failed = Queue::list_failed(10);
        $count = Queue::count_failed();
        $settings_url = admin_url('options-general.php?page=cf7stg-settings');
        include CF7STG_PLUGIN_DIR . 'admin/views/dashboard-widget.php';
    }
}
  • Step 2: Create admin/views/dashboard-widget.php
<?php
/** @var array<int,object> $failed */
/** @var int $count */
/** @var string $settings_url */
?>
<p>Есть <b><?php echo (int)$count; ?></b> неотправленных сообщений (TG-API отвечает ошибкой):</p>
<ul style="margin:0 0 8px 18px;list-style:disc">
<?php foreach ($failed as $row): ?>
    <li>
        <?php echo esc_html((string)$row->created_at); ?> —
        <?php echo esc_html((string)$row->last_error); ?>
    </li>
<?php endforeach; ?>
</ul>
<p><a href="<?php echo esc_url($settings_url); ?>" class="button">Открыть настройки →</a></p>
  • Step 3: Commit
git add admin/class-dashboard-widget.php admin/views/dashboard-widget.php
git commit -m "feat(admin): dashboard widget shown only when there are failed queue items"

Task 23: uninstall.php + readme.txt

Files:

  • Create: uninstall.php

  • Create: readme.txt

  • Step 1: Create uninstall.php

<?php
// If uninstall not called from WordPress, exit.
if (!defined('WP_UNINSTALL_PLUGIN')) exit;

global $wpdb;
$table = $wpdb->prefix . 'cf7stg_queue';
$wpdb->query("DROP TABLE IF EXISTS {$table}");

$options = [
    'cf7stg_bot_token_enc',
    'cf7stg_chat_id',
    'cf7stg_site_title',
    'cf7stg_last_test',
    'cf7stg_db_version',
];
foreach ($options as $opt) {
    delete_option($opt);
}

wp_clear_scheduled_hook('cf7stg_dispatch');
wp_clear_scheduled_hook('cf7stg_cleanup');
  • Step 2: Create readme.txt
=== CF7 Spam → Telegram ===
Contributors: vbryzgalov
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.0
License: GPLv2 or later

Пересылает в Telegram-группу submissions CF7, помеченные как спам, с эвристической оценкой «похоже клиент / неясно / похоже спам».

== Description ==

Решает узкую задачу: среди отсечённого Akismet/Honeypot спама иногда встречаются реальные заявки клиентов. Плагин пересылает всё содержимое таких submissions в общую TG-группу с пометкой классификации (🟢/🟡/🔴), причиной попадания в спам, IP/UA, UTM.

Требует установленных плагинов Contact Form 7 и Flamingo.

== Installation ==

1. Загрузите папку `cf7-spam-to-telegram` в `/wp-content/plugins/`.
2. Активируйте плагин в меню «Плагины».
3. Откройте «Настройки → CF7 Spam → TG», укажите Bot Token и Chat ID группы, сохраните.
4. Нажмите «Отправить тестовое сообщение», чтобы проверить.

== Changelog ==

= 1.0.0 =
* Первый релиз.
  • Step 3: Commit
git add uninstall.php readme.txt
git commit -m "chore: uninstall cleanup, WP readme"

Task 24: Full test suite + lint pass

  • Step 1: Run full suite

Run: ./vendor/bin/phpunit Expected: all tests PASS across ClassifierTest, PhoneParserTest, DomainFormatterTest, MessageFormatterTest.

  • Step 2: Lint all PHP files

Run:

find . -path ./vendor -prune -o -name '*.php' -print -exec php -l {} \; | grep -v 'No syntax errors' || true

Expected: only file names listed (with No syntax errors suppressed); no error lines.

  • Step 3: Commit (if lint produced any fixes)

If lint found nothing, skip. Otherwise:

git add -A
git commit -m "fix: lint pass"

Task 25: Pilot deploy to washanyanya.ru (manual)

Files: (none — delivery task)

This task is executed by the developer, not the agent.

  • Step 1: Package a release zip

Run:

cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+"
git -C cf7-spam-to-telegram archive --format=zip -o cf7-spam-to-telegram-1.0.0.zip HEAD
  • Step 2: Upload via wp-admin

Open https://washanyanya.ru/wp-admin/plugin-install.php?tab=upload, upload the zip, activate.

  • Step 3: Create a Telegram bot and group
  1. In BotFather: /newbot, save token.
  2. Create a Telegram group, add the bot, make it admin.
  3. Send any message in the group.
  4. Open https://api.telegram.org/bot<TOKEN>/getUpdates in a browser to find chat.id (negative for groups).
  • Step 4: Configure the plugin

In wp-admin → «Настройки → CF7 Spam → TG»:

  • Enter Bot Token.

  • Enter Chat ID.

  • Leave site title blank (it will show washanyanya.ru).

  • Click «Отправить тестовое сообщение».

  • Verify message arrives in TG group.

  • Step 5: Trigger a real spam-like submission

On the site, submit a form with obvious spam content (URL + "SEO"). Wait up to 1 minute.

Expected: a message arrives in TG with 🔴 label and reasons listed.

  • Step 6: Test retro import

In settings → Ретроспективная выгрузка → count=5, days=30, press «Добавить в очередь».

Expected: "Добавлено N, пропущено M" notice. Within a minute, N messages arrive in TG with 📂 Ретроспектива prefix.

  • Step 7: Close the epic
cd "/Users/vladimirbryzgalov/Yandex.Disk.localized/Работа/Сайты/+Разработка сайтов+/cf7-spam-to-telegram"
bd close cf7stg-b2c --reason "Pilot on washanyanya.ru green; ready to roll out to other sites"

Plan Self-Review

  • Spec coverage: Every section from the spec is represented — §§1-4 (goal/env/approach/slug) → Task 20 bootstrap + readme.txt; §5 file structure → mirrored by tasks; §6 CF7 flow → Task 17; §7 DB schema → Task 12 + Task 19; §8 Classifier → Tasks 6-7; §9 MessageFormatter → Tasks 8-9; §10 Retro import → Task 18 + Task 21 admin UI; §11 Settings UI → Task 21; §12 Security (token encryption) → Tasks 10-11; §13 WP-Cron → Task 15 + Task 19; §14 Dashboard widget → Task 22; §15 Error handling → Tasks 13-15; §16 Tests → Tasks 2-9; §17 Lifecycle → Tasks 19, 20, 23; §18 Deploy → Task 25.

  • Placeholder scan: No TBD/TODO/"similar to". All steps contain full code or full commands.

  • Type/name consistency: Queue::count_failed, Queue::list_failed, Settings::is_bot_token_constant, MessageFormatter::build, Classifier::classify, PhoneParser::parse, DomainFormatter::humanize — consistent across all tasks. Table name {prefix}cf7stg_queue, option names prefixed cf7stg_*, hooks cf7stg_dispatch / cf7stg_cleanup, CRON schedule cf7stg_every_minute — consistent.


Plan complete. Ready for execution.