Pilot on washanyanya.ru exposed that Telegram Bot API rejects tel: URLs in inline_keyboard with HTTP 400 "Wrong port number specified in the URL". Queue was stuck on 5 items retrying the same failure 4 times each. - MessageFormatter: always returns reply_markup = null. The phone number is already rendered in the '📱 ...' line, and mobile Telegram clients linkify it automatically. - TelegramClient::is_permanent: treat all 4xx except 429 as permanent client-side errors, so future payload-shape bugs surface in the dashboard widget immediately instead of burning five retry cycles. - MessageFormatterTest updated: the old test asserting tel: in reply_markup is replaced with one asserting reply_markup is null and the phone still appears in the text body. - Spec §9 + §19 updated, §19's open question closed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
94 KiB
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_reply_markup_is_null_because_tg_rejects_tel_urls(): void
{
// Telegram Bot API rejects tel: URLs in inline_keyboard with
// HTTP 400 "Wrong port number specified in the URL". We no longer
// emit any inline button; the phone number is rendered in the
// message body instead and mobile TG clients auto-linkify it.
$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::assertNull($r['reply_markup']);
// Phone must still be present in the text, formatted as Russian pretty.
self::assertStringContainsString('+7 (903) 123-45-67', $r['text']);
}
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('<script>', $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);
// Telegram Bot API rejects tel: URLs in inline_keyboard with HTTP 400
// ("Wrong port number specified"), so no inline button is emitted.
// The phone number is already shown in the message body (line '📱 ...')
// and mobile Telegram clients make such numbers tappable automatically.
return ['text' => $text, 'reply_markup' => null];
}
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
Cryptoclass
<?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
Settingsclass
<?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
Queueclass
<?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
{
// 4xx (except 429 rate-limit) are client-side errors: bad request,
// invalid chat, revoked token, malformed payload — retrying will not
// help. Mark as permanent so the queue gives up immediately and the
// admin is notified via the dashboard widget.
if ($code >= 400 && $code < 500 && $code !== 429) 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.
*
* 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
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> </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
- In BotFather:
/newbot, save token. - Create a Telegram group, add the bot, make it admin.
- Send any message in the group.
- Open
https://api.telegram.org/bot<TOKEN>/getUpdatesin a browser to findchat.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 prefixedcf7stg_*, hookscf7stg_dispatch/cf7stg_cleanup, CRON schedulecf7stg_every_minute— consistent.
Plan complete. Ready for execution.