3a998c36d2
Independent WP plugin that forwards CF7 spam submissions to a Telegram group, applying a weighted heuristic (client/unclear/spam) to help operators spot real inquiries mistakenly filtered as spam. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
402 lines
26 KiB
Markdown
402 lines
26 KiB
Markdown
# 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-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)` — для 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** |
|
||
|
||
**Ключевые слова (дефолт, матчинг по подстрокам через `mb_stripos`, регистронезависимо):**
|
||
|
||
- SEO/продвижение: `SEO`, `СЕО`, `продвижение сайта`, `backlinks`, `обратные ссылки`, `позиции в поиске`
|
||
- Финансы: `bitcoin`, `биткоин`, `crypto`, `криптовалют`, `forex`, `форекс`, `trading`, `трейдинг`, `loan`, `займ`, `микрозайм`, `инвестиции`, `заработок онлайн`, `удалённая работа без`, `MLM`, `сетевой маркетинг`
|
||
- Казино: `casino`, `казино`, `ставки`, `betting`, `букмекер`
|
||
- БАДы/диеты: `бад`, `биодобав`, `супер фуд`, `super food`, `детокс`, `похудение`, `похудет`, `диетическ`, `жиросжиг`
|
||
- Мед. препараты: `viagra`, `виагра`, `cialis`, `сиалис`, `pharmacy`, `фарма`, `дженерик`
|
||
- Порно/эскорт: `porn`, `xxx`, `adult`, `porno`, `порно`, `эскорт`, `проститу`, `секс-знакомств`, `интим-услуг`
|
||
|
||
**Пороги:**
|
||
- `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]);
|
||
```
|
||
|
||
## 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-клавиатура:**
|
||
- Если распарсился валидный RU-телефон — одна кнопка `"📞 Позвонить: {phone_formatted}"` → `url: "tel:{e164}"`.
|
||
- Без телефона — `reply_markup = null` (кнопок нет).
|
||
|
||
**Site title:** `Settings::get_site_title()` возвращает либо оверрайд из настроек, либо декодированный через `idn_to_utf8()` host из `home_url()`. Например, `xn--80aafmakjldfe1b5h.xn--p1ai` → `вашаняня.рф`.
|
||
|
||
## 10. Ретроспективная выгрузка
|
||
|
||
**UI:** на странице настроек — блок «Ретроспективная выгрузка из Flamingo»:
|
||
- `count` (int, default 10, max 50)
|
||
- `days` (int, default 7; 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', ...)` → при `Queue::count_failed() > 0` регистрируется виджет `cf7stg_dashboard_errors`:
|
||
|
||
```
|
||
⚠️ CF7 Spam → Telegram: ошибки доставки
|
||
Есть N неотправленных сообщений (TG-API отвечает ошибкой):
|
||
|
||
• {created_at} — {last_error}
|
||
• ...
|
||
|
||
[Открыть настройки →]
|
||
```
|
||
|
||
При `count_failed() == 0` — виджет не регистрируется, дашборд чистый.
|
||
|
||
## 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.** Bot API документация разрешает только `http/https/tg`, но практика показывает, что `tel:` работает в клиентах. Проверить на первом пилотном сайте; если Telegram отклонит — fallback: показать номер текстом без кнопки, добавить префикс `📞 ` для ясности.
|
||
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-символы, не требуют разметки).
|