diff --git a/docs/superpowers/plans/2026-04-17-cf7-spam-to-telegram.md b/docs/superpowers/plans/2026-04-17-cf7-spam-to-telegram.md new file mode 100644 index 0000000..f633643 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-cf7-spam-to-telegram.md @@ -0,0 +1,2863 @@ +# 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 + + + + + tests + + + +``` + +- [ ] **Step 3: Create `tests/bootstrap.php`** + +This bootstrap gives tests access to the pure-logic classes without loading WordPress. + +```php + 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 + 'Анна Петровна', + '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 + 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 $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} + */ + 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 + if (($meta['reason'] ?? '') === 'Honeypot') { + $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'; + } + + 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 + 'Анна Петровна', + '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' => '', '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('