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('