Files
cf7-spam-to-telegram/docs/superpowers/specs/2026-04-17-cf7-spam-to-telegram-design.md
Vladimir Bryzgalov 8efb82ca43 fix: phone leak, field-by-value detection, strict Flamingo email fallback
Three pilot-surfaced issues in one pass:

1. PhoneParser regex used '\s' in the candidate character class, so a
   digit on a following flattened line bled into the phone. '+7977
   6277470\n1' (phone + trailing checkbox value) parsed as 12 digits,
   producing '+797762774701'. Replaced '\s' with a literal space so
   newlines/tabs stop the candidate.

2. Custom-themed forms don't use 'your-name' / 'your-email' keys
   (washanyanya.ru has text-211 / text-212 / name_user). Added
   FieldDetector::find_name + find_email as fallbacks. Classifier
   and MessageFormatter now call them if no standard key matches,
   so 'Александр' in text-211 is recognised as a cyrillic name and
   scored / displayed accordingly.

3. FlamingoHelper copied '_from_email' into 'your-email' without
   validating it. Flamingo derives that meta from whatever field it
   considers "first"; on phone-first forms it ends up holding the
   phone number. Now we only copy _from_email if it contains '@',
   and dropped the _from_name shortcut entirely — FieldDetector
   handles the name via the actual field values.

Tests: +1 FieldDetectorTest (13 cases); +2 PhoneParserTest cases for
newline/tab bleeds; +1 ClassifierTest case; +1 MessageFormatterTest
case for custom field keys. Full suite 53/53 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:22:39 +05:00

418 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CF7 Spam → Telegram — дизайн-документ
**Дата:** 2026-04-17
**Автор:** Владимир Брызгалов
**Статус:** утверждён к планированию реализации
---
## 1. Цель и предпосылки
На сайтах Владимира (27 доменов на Beget, тематика — сиделки/няни/работа) через формы Contact Form 7 регулярно приходит спам. Встроенные фильтры (Akismet, honeypot, `wpcf7_spam`) отсекают его от обычного канала уведомлений — но среди отсечённых **иногда бывают реальные заявки клиентов**. Их теряют.
**Цель плагина:** пересылать в общую Telegram-группу все submissions CF7, помеченные как спам, с полным содержимым формы и эвристической оценкой «похоже клиент / неясно / похоже спам», чтобы менеджеры могли бегло просмотреть отсечённое и не упустить реальные обращения.
**Не-цели:**
- Замена существующего плагина `cf7-telegram` (он остаётся и шлёт обычные «не-спам» заявки своим чередом).
- Борьба со спамом (уменьшение количества) — плагин пассивный наблюдатель.
- Двусторонний канал (ответ клиенту/пометка «не спам» из Telegram). Отложено на потенциальную v2.
## 2. Целевая среда
| Параметр | Значение |
|---|---|
| Хостинг | Beget shared (пилот — `washanyanya.ru`, затем остальные 26 сайтов аккаунта `pasha072`) |
| WordPress | 6.9.4+ |
| PHP | 7.4+ (все целевые сайты имеют доступ до 8.3) |
| Расширения PHP | `intl`, `curl`, `mbstring` (проверено — есть на всех версиях Beget) |
| Зависимости | Contact Form 7 + Flamingo (обязательны). Composer НЕ используется в проде |
| Локализация | Только русский, строки хардкодятся без `__()` |
## 3. Архитектурный выбор
Рассмотрены три подхода:
1. **Независимый плагин** — собственная конфигурация, не зависит от `cf7-telegram`.
2. **Форк `cf7-telegram`** — доработка чужой кодовой базы. Отклонён: дорого в поддержке на 27 сайтах, автообновления ломают форк.
3. **Надстройка над `cf7-telegram`** — переиспользование его опций. Отклонён: он не предоставляет публичного API, залезание в его внутренности хрупко; нельзя разделить спам и обычный поток по разным чатам.
**Выбран подход 1.** Дублирование ~50 строк HTTP-кода приемлемо ради изоляции, независимой конфигурации и свободы выбора отдельной TG-группы для спама.
## 4. Слаг и идентификация
- **Слаг плагина / папка:** `cf7-spam-to-telegram`
- **Текстовый префикс функций/опций/таблицы:** `cf7stg_`
- **Префикс WP-фильтров/хуков расширения:** `cf7stg_`
- **Константы wp-config (опциональные override'ы):** `SPAM2TG_BOT_TOKEN`, `SPAM2TG_CHAT_ID`
## 5. Структура файлов
```
cf7-spam-to-telegram/
├── cf7-spam-to-telegram.php # Главный файл с WP-заголовком, bootstrap
├── uninstall.php # Удаление опций и таблицы очереди
├── includes/
│ ├── class-plugin.php # Singleton, регистрация хуков, автолоадер
│ ├── class-activator.php # Создание таблицы, дефолтные опции
│ ├── class-settings.php # Чтение/запись опций, приоритет констант
│ ├── class-crypto.php # Шифрование токена через AUTH_KEY
│ ├── class-queue.php # CRUD очереди, dedup, retention
│ ├── class-dispatcher.php # WP-Cron тики, отправка, backoff
│ ├── class-telegram-client.php # wp_remote_post, обработка кодов TG API
│ ├── class-telegram-exception.php # TelegramException с флагом is_permanent
│ ├── class-classifier.php # Эвристика, score → label, reasons
│ ├── class-phone-parser.php # Нормализация RU-телефонов
│ ├── class-domain-formatter.php # Punycode → кириллица (idn_to_utf8)
│ ├── class-field-detector.php # Fallback поиск имени/email когда ключи формы нестандартны
│ ├── class-message-formatter.php # Сборка HTML-сообщения
│ ├── class-retro-importer.php # Ретро-выгрузка из Flamingo
│ ├── class-flamingo-helper.php # Извлечение полей и reason из Flamingo
│ └── sources/
│ ├── interface-spam-source.php
│ └── class-cf7-source.php # Хуки wpcf7_spam / wpcf7_submission
├── admin/
│ ├── class-settings-page.php # Страница Настройки → CF7 Spam → TG
│ ├── class-dashboard-widget.php # Виджет, показ только при ошибках
│ └── views/
│ ├── settings.php
│ └── dashboard-widget.php
├── tests/
│ ├── bootstrap.php # Автолоадер для юнит-тестов
│ ├── ClassifierTest.php
│ ├── PhoneParserTest.php
│ ├── DomainFormatterTest.php
│ └── MessageFormatterTest.php
├── composer.json # Только dev-зависимости (phpunit)
├── phpunit.xml
├── .gitignore
└── readme.txt # Стандартный WP readme (краткий)
```
**Автолоадер:** простой `spl_autoload_register` в `class-plugin.php`, маппинг `Cf7stg\ClassName``includes/class-class-name.php`. Composer используется только для dev (PHPUnit).
## 6. Поток обработки входящего spam-события
```
┌────────────────────────────────────────────────────────────────┐
│ CF7 form submit │
└─────────────┬──────────────────────────────────────────────────┘
[ filter wpcf7_spam, priority 9999 ] ←── основной триггер
│ если $spam === true:
CF7Source::handle_spam_submission($submission)
├─► Flamingo сохраняет запись (priority 10,
│ до или после нашего хука — неважно)
┌───────────────────────────────────────┐
│ Сбор полей: $submission->get_posted_data() │
│ Определение reason (Akismet/Honeypot/…) │
│ Извлечение IP, UA, UTM, Referer │
│ Classifier::classify(fields) → label │
│ MessageFormatter::build(...) → text + markup│
│ Flamingo post ID (через Flamingo_Helper) │
│ Queue::enqueue(payload) │
└───────────────────────────────────────┘
return $spam (без изменений)
```
Fallback — `action wpcf7_submission, priority 20`: если `$submission->get_status() === 'spam'` и очередь ещё не содержит соответствующий `flamingo_post_id` — добавляем запись (страховка на случай honeypot'ов, которые ставят статус напрямую, минуя фильтр `wpcf7_spam`).
```
┌────────────────────────────────────────────────────────────────┐
│ WP-Cron (кастомный интервал every_minute) │
└─────────────┬──────────────────────────────────────────────────┘
Dispatcher::tick()
Queue::fetch_pending(limit=5, where next_try_at <= NOW)
Для каждой записи:
TelegramClient::send_message(text, reply_markup)
├─ success → Queue::mark_sent(id, now)
├─ temp fail (5xx/429/network) → Queue::reschedule(id, attempts+1,
│ backoff[60, 300, 1800, 3600])
└─ perm fail (401/403/400 chat not found) или attempts>=5
→ Queue::mark_failed(id, err)
usleep(100ms) между отправками (TG rate-limit ~30 msg/s)
```
## 7. Схема БД
Таблица `{prefix}cf7stg_queue`:
| Поле | Тип | Комментарий |
|---|---|---|
| `id` | `BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY` | |
| `flamingo_post_id` | `BIGINT UNSIGNED NULL` | id записи Flamingo; NULL если не создалась |
| `form_id` | `BIGINT UNSIGNED NULL` | id CF7-формы |
| `source_key` | `VARCHAR(40) NOT NULL` | `'cf7'` (задел под другие источники) |
| `payload_json` | `LONGTEXT NOT NULL` | `{text, reply_markup}` в JSON |
| `label` | `VARCHAR(10) NOT NULL` | `client` / `unclear` / `spam` |
| `is_retro` | `TINYINT(1) NOT NULL DEFAULT 0` | 1 если из ретро-выгрузки |
| `status` | `VARCHAR(20) NOT NULL DEFAULT 'pending'` | `pending` / `sent` / `failed` |
| `attempts` | `SMALLINT UNSIGNED NOT NULL DEFAULT 0` | |
| `last_error` | `TEXT NULL` | последняя ошибка TG |
| `next_try_at` | `DATETIME NULL` | для backoff |
| `created_at` | `DATETIME NOT NULL` | |
| `sent_at` | `DATETIME NULL` | |
**Индексы:**
- `KEY idx_status_next (status, next_try_at)` — для Dispatcher
- `UNIQUE KEY uq_flamingo (flamingo_post_id, is_retro)` — dedup
**Retention:** ежедневный WP-Cron `cf7stg_cleanup` удаляет записи `status='sent'` старше 30 дней.
## 8. Classifier — эвристика
Метод: `Classifier::classify(array $fields, array $meta): array` возвращает:
```php
[
'score' => int, // итоговый балл
'label' => 'client' | 'unclear' | 'spam',
'reasons' => [ // причины со знаком и весом
['sign' => '+', 'weight' => 3, 'text' => 'телефон RU'],
['sign' => '-', 'weight' => 2, 'text' => 'ключевое слово: SEO'],
...
],
]
```
**Веса (дефолт):**
| Признак | Вес |
|---|---|
| Телефон парсится как RU (+7/8, 10-11 цифр) | **+3** |
| Имя — только кириллица, без цифр/URL | +1 |
| E-mail на ру-домене (yandex.ru, mail.ru, gmail.com, rambler.ru, ya.ru) | +1 |
| Текст — кириллица, > 10 симв., не одна ссылка | +1 |
| URL в любом поле (`http://`, `https://`, `www.`) | **3** |
| Ключевое слово (см. список) — `2` за вхождение, капается на **6** | 2 × min(n, 3) |
| Имя или текст — только латиница | −1 |
| Honeypot сработал (reason='Honeypot') | **5** *(и hard override: label=spam)* |
**Ключевые слова (дефолт, матчинг по подстрокам через `mb_stripos`, регистронезависимо):**
- SEO/продвижение: `SEO`, `СЕО`, `продвижение сайта`, `backlinks`, `обратные ссылки`, `позиции в поиске`
- Финансы: `bitcoin`, `биткоин`, `crypto`, `криптовалют`, `forex`, `форекс`, `trading`, `трейдинг`, `loan`, `займ`, `микрозайм`, `инвестиции`, `заработок онлайн`, `удалённая работа без`, `MLM`, `сетевой маркетинг`
- Казино: `casino`, `казино`, `ставки`, `betting`, `букмекер`
- БАДы/диеты: `бад`, `биодобав`, `супер фуд`, `super food`, `детокс`, `похудение`, `похудет`, `диетическ`, `жиросжиг`
- Мед. препараты: `viagra`, `виагра`, `cialis`, `сиалис`, `pharmacy`, `фарма`, `дженерик`
- Порно/эскорт: `porn`, `xxx`, `adult`, `porno`, `порно`, `эскорт`, `проститу`, `секс-знакомств`, `интим-услуг`
**Пороги:**
- если `reason === 'Honeypot'``label = 'spam'` **(hard override, всегда)**
- иначе, если `score >= 3``label = 'client'` (🟢 похоже клиент)
- иначе, если `score <= -2``label = 'spam'` (🔴 похоже спам)
- иначе → `label = 'unclear'` (🟡 неясно)
**Кастомизация** — через WP-фильтры:
```php
apply_filters('cf7stg_classifier_weights', $default_weights);
apply_filters('cf7stg_classifier_keywords', $default_keywords);
apply_filters('cf7stg_classifier_thresholds', ['client' => 3, 'spam' => -2]);
```
**Поиск полей формы.** Сначала пробуются известные CF7-ключи — `your-name`, `name`, `имя`, `fio` для имени; `your-email`, `email`, `e-mail`, `mail` для e-mail; `your-message`, `message`, `comment`, `сообщение`, `вопрос` для текста. Если ничего не нашлось (например, на сайте форма с кастомными именами полей `text-211`, `name_user` и т.п.), подключается `FieldDetector`:
- `find_name(fields)` — первое значение без цифр/URL/@, длиной 2..80, состоящее только из letter-characters (любой скрипт), пробелов, дефисов и апострофов.
- `find_email(fields)` — первое значение, матчащее `^\S+@\S+\.\S+$`.
Телефон всегда ищется `PhoneParser::parse()` по конкатенации всех полей — имена ключей не важны.
## 9. MessageFormatter — формат сообщения в Telegram
- **parse_mode:** `HTML` (строже и предсказуемее, чем MarkdownV2).
- Все пользовательские значения прогоняются через `esc_html()` перед вставкой.
- Лимит 4096 символов. Переполнение отсекается в теле `💬`, остаток заменяется `…[обрезано]`.
- Для ретро-выгрузки добавляется префикс `📂 Ретроспектива · ` перед меткой классификации.
**Шаблон:**
```
{retro_prefix}{label_emoji} {label_text}
🌐 {site_title} · форма «{form_title}»
🗓 {submitted_at}
📱 {phone_formatted | "(не указан)"}
👤 {name | "(не указано)"}
📧 {email | "(не указан)"}
💬 {message | "(пусто)"}
⚙️ Попало в спам: {reason}
📊 Классификация: {reasons_joined_by_comma}
🌍 IP {ip} · UA {user_agent_short}
{utm_block_if_any}
{other_fields_block_if_any}
```
**Inline-клавиатура:**
- `reply_markup = null` всегда. Telegram Bot API отклоняет `tel:` URLs в inline_keyboard с ошибкой HTTP 400 `Wrong port number specified in the URL`. Номер показывается в теле сообщения (строка `📱 ...`), мобильный TG-клиент автоматически делает его кликабельным.
**Site title:** `Settings::get_site_title()` возвращает либо оверрайд из настроек, либо декодированный через `idn_to_utf8()` host из `home_url()`. Например, `xn--80aae0ca7d8bb.xn--p1ai``вашаняня.рф`.
## 10. Ретроспективная выгрузка
**UI:** на странице настроек — блок «Ретроспективная выгрузка из Flamingo»:
- `count` (int, default 10, max 50)
- `days` (int, default 0; 0 = без ограничения)
- `allow_repeat` (checkbox)
- кнопка «Добавить в очередь»
**Обработка (синхронно при нажатии):**
```php
RetroImporter::import(int $count, int $days, bool $allow_repeat): array {
// WP_Query по flamingo_inbound со статусом flamingo-spam,
// orderby=date DESC, posts_per_page=min(count, 50),
// фильтр по дате если $days > 0
//
// Для каждой записи:
// if (!$allow_repeat && Queue::exists_for_flamingo($post->ID)) skip;
// извлечь поля → Classifier → MessageFormatter (is_retro=true) → Queue::enqueue
//
// return ['added' => N, 'skipped' => M]
}
```
UI сообщает: «Добавлено 8, пропущено 2 (уже отправлено ранее)». Собственно доставка в TG — через общий WP-Cron цикл, в течение минуты.
## 11. Страница настроек
Путь: **Настройки → CF7 Spam → Telegram** (`Settings API`).
**Поля:**
- Bot Token — `password`, шифруется при сохранении (см. §12). Если задан `SPAM2TG_BOT_TOKEN` — поле readonly с подсказкой.
- Chat ID — `text`. Если задан `SPAM2TG_CHAT_ID` — readonly.
- Заголовок сайта — `text`, пустое значение = авто-декодированный домен.
- Кнопка «Отправить тестовое сообщение» — шлёт пробный текст в настроенный чат, показывает статус.
- Блок «Ретроспективная выгрузка» — см. §10.
- Блок «Состояние очереди» — счётчики pending/sent-за-сутки/failed + кнопки «Повторить все ошибочные» и «Очистить отправленные».
**Capability:** `manage_options`. Все POST-формы — с `wp_nonce_field` + проверка `check_admin_referer`.
## 12. Безопасность
- **Токен в БД шифруется** алгоритмом `openssl_encrypt('AES-256-CBC', ...)`, ключ — `hash_hmac('sha256', 'cf7stg_token', AUTH_KEY)`, IV хранится рядом с шифротекстом. При чтении:
- попытка дешифровать. Если ключ изменился (`AUTH_KEY` перевыпустили) — дешифровка упадёт; UI показывает «Токен повреждён, введите заново».
- **Константы wp-config** (`SPAM2TG_BOT_TOKEN`, `SPAM2TG_CHAT_ID`) имеют приоритет над БД. Когда активны — поля в UI readonly.
- Все входы админки — через `capability manage_options` + nonce.
- Все внешние значения в TG-сообщениях — через `esc_html()`.
- `TelegramClient` — через `wp_remote_post` (автоматически использует настройки прокси WP, timeout=10s). Никаких `curl_exec`/`fopen`/`exec`.
## 13. WP-Cron
**Регистрация при активации (`register_activation_hook`):**
```php
if (!wp_next_scheduled('cf7stg_dispatch')) {
wp_schedule_event(time(), 'cf7stg_every_minute', 'cf7stg_dispatch');
}
if (!wp_next_scheduled('cf7stg_cleanup')) {
wp_schedule_event(time() + 3600, 'daily', 'cf7stg_cleanup');
}
```
**Кастомный интервал** — через `cron_schedules` фильтр:
```php
$schedules['cf7stg_every_minute'] = ['interval' => 60, 'display' => 'Раз в минуту (CF7 Spam → TG)'];
```
**Деактивация (`register_deactivation_hook`):** снимаем оба события через `wp_unschedule_event`.
**Ограничения shared-хостинга:** WP-Cron срабатывает при HTTP-запросах к сайту. При малом трафике события могут опаздывать. В UI настроек — короткая подсказка: «Для точности можно настроить внешний cron на `wp-cron.php`».
## 14. Dashboard-виджет
`add_action('wp_dashboard_setup', ...)` регистрирует виджет `cf7stg_dashboard_status` **всегда** (после пилота: пользователь ожидает увидеть быстрый статус, а не ориентироваться только на появление «тревожной» лампы).
Заголовок зависит от состояния:
- `failed > 0``⚠️ CF7 Spam → Telegram: ошибки доставки`
- иначе → `CF7 Spam → Telegram`
Содержимое:
```
[если !is_configured: ⚙️ Плагин не настроен — укажите Bot Token и Chat ID в настройках.]
⏳ В очереди: N · ✅ За сутки: M · [✖|⚠️] Ошибки: K
[если K > 0:
Последние ошибки доставки (TG-API):
• {created_at} — {last_error}
• ...
]
[Открыть настройки →]
```
## 15. Обработка ошибок Telegram API
`TelegramClient::send_message()` бросает `TelegramException`, у которого метод `is_permanent(): bool`.
**Перманентные (сразу `failed`, без ретраев):**
- HTTP 401 — токен неверный
- HTTP 403 — бот забанен/удалён из группы
- HTTP 400 + описание `chat not found` — неправильный chat_id
- HTTP 400 + `bot was blocked by the user` (если кто-то случайно настроил личный чат)
**Временные (ретрай с backoff):**
- HTTP 429 — чтимо `retry_after` из ответа (не меньше заданного backoff)
- HTTP 500/502/503/504 — ошибка сервера TG
- сетевой таймаут / `WP_Error` от `wp_remote_post`
**Backoff:** попытки 1→2→3→4→5 → паузы 60/300/1800/3600/3600 секунд. После 5 попыток — `failed`.
## 16. Тесты (PHPUnit, без WP)
**Запуск:** `composer install`, `vendor/bin/phpunit`. На проде composer не нужен, в `.gitignore``vendor/` и `composer.lock`.
**Покрываются (целевые классы):**
| Класс | Кейсов | Темы |
|---|---|---|
| `ClassifierTest` | ~15 | реальная заявка → client; спам с URL+SEO → spam; honeypot перекрывает положительные сигналы; пустые поля → unclear; несколько ключевых слов капаются на −6; фильтры кастомизации |
| `PhoneParserTest` | ~10 | форматы `+7(...)...`, `8...`, зарубежный → is_russian=false, несколько телефонов → первый, мусор → null |
| `DomainFormatterTest` | ~5 | xn-- → кириллица, обычные домены → без изменений, некорректный IDN → как есть без exception |
| `MessageFormatterTest` | ~8 | полный набор полей → ожидаемый HTML; без телефона → без inline-кнопки; длинное сообщение → обрезается; HTML-инъекция в поле → экранирование; префикс ретро |
**Не покрываются автотестами:**
- `Queue`, `Dispatcher`, `CF7Source`, `TelegramClient`, админ-страницы — требуют WP-окружения.
- Проверяются вручную: кнопка «Тестовое сообщение», пилот на `washanyanya.ru`, отправка реальной спам-формы.
## 17. Жизненный цикл плагина
**Activation (`register_activation_hook`):**
1. Проверка минимальной версии PHP (7.4) и WP (6.0+). При несоответствии — `wp_die` с понятным сообщением.
2. Создание таблицы `{prefix}cf7stg_queue` через `dbDelta`.
3. Установка дефолтных опций, если не заданы.
4. Регистрация WP-Cron событий.
**Deactivation (`register_deactivation_hook`):**
1. Снятие WP-Cron событий.
2. Таблица и опции остаются (на случай реактивации).
**Uninstall (`uninstall.php`):**
1. Удаление таблицы.
2. Удаление всех `cf7stg_*` опций.
## 18. Деплой на сайты (за рамками MVP спеки)
Плагин — стандартный WP-плагин, упаковывается в `.zip` командой вида `git archive` или ручным zip-ом папки. Установка через wp-admin «Плагины → Добавить → Загрузить». Массовая раскатка на 27 сайтов — отдельная задача, в текущий спек не входит.
## 19. Открытые вопросы (решаем во время реализации)
1. ~~**`tel:`-ссылки в inline-кнопках Telegram.**~~ **РЕШЕНО (2026-04-17 пилот).** Telegram Bot API отклонил `tel:` URLs (HTTP 400, `Wrong port number specified in the URL`). Inline-кнопки полностью убраны; номер показывается в теле сообщения, мобильные клиенты сами делают его кликабельным.
2. **Извлечение `flamingo_post_id`.** Flamingo не даёт прямой ссылки submission→post после сохранения. План: после сохранения Flamingo (`wpcf7_submission` priority 20) искать в `wp_posts` записи `post_type=flamingo_inbound`, sort by date DESC, limit 1, фильтр по содержимому `post_content` (сравнение с полями submission). Если совпадение ненадёжное — плагин всё равно работает (`flamingo_post_id = NULL`, dedup ретро-выгрузки пропускается для этой записи, но основной поток не зависит от наличия id).
3. **Акцент HTML vs MarkdownV2.** Если в процессе пилота выяснится, что клиенты часто пишут символы, ломающие HTML даже после экранирования — рассмотреть переход на plain text (без форматирования, эмодзи — unicode-символы, не требуют разметки).