Files
cf7-spam-to-telegram/docs/superpowers/plans/2026-04-17-cf7-spam-to-telegram.md
T
Vladimir Bryzgalov 1f89052f72 fix(flamingo-helper): extract field values from _field_* meta, not _fields
Flamingo's '_fields' meta stores only the list of field NAMES with
null values; the actual values live in separate per-field meta keys
prefixed with '_field_'. The old extract_fields() returned the
null-valued '_fields' array on the first branch and never fell
through to the '_field_*' collector, so every retro Telegram message
showed '(не указан)' / '(пусто)' for every slot.

Also collect '_from_name' / '_from_email' (set by Akismet) as
convenience mappings to 'your-name' / 'your-email' so the formatter's
standard slots are populated for most submissions even when the form
uses custom field names like text-211 / name_user.

Pilot on washanyanya.ru caught this (retro delivered empty skeletons).

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

2899 lines
94 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`**
```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
<?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
<?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:
```bash
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**
```bash
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
<?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
<?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**
```bash
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
<?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
<?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**
```bash
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
<?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
<?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**
```bash
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
<?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
<?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**
```bash
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
<?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**
```bash
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
<?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**
```bash
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
<?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**
```bash
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
<?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**
```bash
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
<?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**
```bash
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
<?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**
```bash
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
<?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.
*
* Flamingo's '_fields' meta stores only the field *names* with null values;
* the actual values live in per-field meta keys prefixed with '_field_'.
* Collect them directly.
*
* @return array<string,mixed>
*/
public static function extract_fields(\WP_Post $post): array
{
$meta = get_post_meta($post->ID);
$out = [];
foreach ($meta as $key => $values) {
if (strpos($key, '_field_') !== 0) continue;
$name = substr($key, strlen('_field_'));
$raw = $values[0] ?? '';
$val = maybe_unserialize($raw);
if (is_array($val)) {
$val = implode(', ', array_map('strval', $val));
}
if ((string)$val !== '') {
$out[$name] = $val;
}
}
// Convenience: expose Akismet's "from" name / email so the formatter's
// standard slots aren't empty for most submissions.
$from_name = (string)get_post_meta($post->ID, '_from_name', true);
$from_email = (string)get_post_meta($post->ID, '_from_email', true);
if ($from_name !== '' && !isset($out['your-name'])) $out['your-name'] = $from_name;
if ($from_email !== '' && !isset($out['your-email'])) $out['your-email'] = $from_email;
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**
```bash
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
<?php
declare(strict_types=1);
namespace Cf7stg;
interface Interface_Spam_Source
{
public function register(): void;
}
```
- [ ] **Step 2: Create `CF7Source`**
```php
<?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**
```bash
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
<?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**
```bash
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
<?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**
```bash
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
<?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
<?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**
```bash
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
<?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
<?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**
```bash
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
<?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
<?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**
```bash
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
<?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**
```bash
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:
```bash
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:
```bash
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:
```bash
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**
```bash
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.