22239321d0
Dispatcher::register_hooks() runs on plugins_loaded, which is AFTER register_activation_hook fires. The custom recurrence 'cf7stg_every_minute' was therefore unknown to wp_schedule_event during activation, and the 'cf7stg_dispatch' event silently failed to schedule — queue items never got dispatched. Pilot caught this on washanyanya.ru (23 items queued, 0 sent, 0 failed — no cron hook firing at all). Adding the filter directly inside Activator::schedule_events() makes the custom schedule available in the same request as the wp_schedule_event call, regardless of bootstrap order. After updating the plugin, deactivate + reactivate is required to re-trigger Activator::activate() so events get (re)scheduled. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2885 lines
93 KiB
Markdown
2885 lines
93 KiB
Markdown
# 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('<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
|
||
<?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.
|
||
*
|
||
* @return array<string,mixed>
|
||
*/
|
||
public static function extract_fields(\WP_Post $post): array
|
||
{
|
||
$fields = get_post_meta($post->ID, '_fields', true);
|
||
if (is_array($fields)) return $fields;
|
||
// Fallback: meta stored under Flamingo's structure
|
||
$meta = get_post_meta($post->ID);
|
||
$out = [];
|
||
foreach ($meta as $key => $values) {
|
||
if (strpos($key, '_field_') === 0) {
|
||
$out[substr($key, strlen('_field_'))] = maybe_unserialize($values[0] ?? '');
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
public static function extract_reason(\WP_Post $post): string
|
||
{
|
||
$log = (string)get_post_meta($post->ID, '_akismet', true);
|
||
if ($log !== '') return 'Akismet';
|
||
return 'Неизвестно';
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```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> </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.
|