- Dashboard widget is now registered unconditionally. Header + content adapt: shows '⚠️ ошибки доставки' only when failed > 0, otherwise plain title with queue state (pending / sent-24h / failed). Pilot feedback: the conditional widget felt like the plugin was missing — users expect a persistent status readout. - Retro default window changed from 7 days to 0 (no limit). Most pilot uses were 'dump everything since install', so 0 is the more useful default. User can still narrow to N days in the form. - Spec §10 + §14 updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
27 KiB
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. Архитектурный выбор
Рассмотрены три подхода:
- Независимый плагин — собственная конфигурация, не зависит от
cf7-telegram. - Форк
cf7-telegram— доработка чужой кодовой базы. Отклонён: дорого в поддержке на 27 сайтах, автообновления ломают форк. - Надстройка над
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-message-formatter.php # Сборка HTML-сообщения, inline-клавиатура
│ ├── 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)— для DispatcherUNIQUE 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 возвращает:
[
'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-фильтры:
apply_filters('cf7stg_classifier_weights', $default_weights);
apply_filters('cf7stg_classifier_keywords', $default_keywords);
apply_filters('cf7stg_classifier_thresholds', ['client' => 3, 'spam' => -2]);
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 400Wrong 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)- кнопка «Добавить в очередь»
Обработка (синхронно при нажатии):
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):
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 фильтр:
$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):
- Проверка минимальной версии PHP (7.4) и WP (6.0+). При несоответствии —
wp_dieс понятным сообщением. - Создание таблицы
{prefix}cf7stg_queueчерезdbDelta. - Установка дефолтных опций, если не заданы.
- Регистрация WP-Cron событий.
Deactivation (register_deactivation_hook):
- Снятие WP-Cron событий.
- Таблица и опции остаются (на случай реактивации).
Uninstall (uninstall.php):
- Удаление таблицы.
- Удаление всех
cf7stg_*опций.
18. Деплой на сайты (за рамками MVP спеки)
Плагин — стандартный WP-плагин, упаковывается в .zip командой вида git archive или ручным zip-ом папки. Установка через wp-admin «Плагины → Добавить → Загрузить». Массовая раскатка на 27 сайтов — отдельная задача, в текущий спек не входит.
19. Открытые вопросы (решаем во время реализации)
РЕШЕНО (2026-04-17 пилот). Telegram Bot API отклонилtel:-ссылки в inline-кнопках Telegram.tel:URLs (HTTP 400,Wrong port number specified in the URL). Inline-кнопки полностью убраны; номер показывается в теле сообщения, мобильные клиенты сами делают его кликабельным.- Извлечение
flamingo_post_id. Flamingo не даёт прямой ссылки submission→post после сохранения. План: после сохранения Flamingo (wpcf7_submissionpriority 20) искать вwp_postsзаписиpost_type=flamingo_inbound, sort by date DESC, limit 1, фильтр по содержимомуpost_content(сравнение с полями submission). Если совпадение ненадёжное — плагин всё равно работает (flamingo_post_id = NULL, dedup ретро-выгрузки пропускается для этой записи, но основной поток не зависит от наличия id). - Акцент HTML vs MarkdownV2. Если в процессе пилота выяснится, что клиенты часто пишут символы, ломающие HTML даже после экранирования — рассмотреть переход на plain text (без форматирования, эмодзи — unicode-символы, не требуют разметки).