diff --git a/includes/class-message-formatter.php b/includes/class-message-formatter.php new file mode 100644 index 0000000..89e83c4 --- /dev/null +++ b/includes/class-message-formatter.php @@ -0,0 +1,168 @@ + ['emoji' => '🟢', 'text' => 'похоже клиент'], + 'unclear' => ['emoji' => '🟡', 'text' => 'неясно'], + 'spam' => ['emoji' => '🔴', 'text' => 'похоже спам'], + ]; + + /** + * @param array{ + * fields:array, + * classification:array{score:int,label:string,reasons:array}, + * site_title:string, + * form_title:string, + * submitted_at:string, + * reason:string, + * ip:string, + * user_agent:string, + * is_retro:bool + * } $ctx + * + * @return array{text:string,reply_markup:array|null} + */ + public static function build(array $ctx): array + { + $fields = $ctx['fields']; + $class = $ctx['classification']; + + $phone = PhoneParser::parse(self::flatten_fields_for_phone($fields)); + $name = self::pick($fields, ['your-name', 'name', 'имя', 'fio']); + $email = self::pick($fields, ['your-email', 'email', 'e-mail', 'mail']); + $message = self::pick($fields, ['your-message', 'message', 'comment', 'сообщение', 'вопрос']); + + $lines = []; + + $label_info = self::LABEL_MAP[$class['label']] ?? self::LABEL_MAP['unclear']; + $header = ($ctx['is_retro'] ? '📂 Ретроспектива · ' : '') + . $label_info['emoji'] . ' ' . $label_info['text']; + + $lines[] = $header; + $lines[] = '🌐 ' . esc_html($ctx['site_title']) . ' · форма «' . esc_html($ctx['form_title']) . '»'; + $lines[] = '🗓 ' . esc_html($ctx['submitted_at']); + $lines[] = ''; + $lines[] = '📱 ' . ($phone !== null ? esc_html($phone['pretty']) : '(не указан)'); + $lines[] = '👤 ' . ($name !== '' ? esc_html($name) : '(не указано)'); + $lines[] = '📧 ' . ($email !== '' ? esc_html($email) : '(не указан)'); + $lines[] = ''; + $lines[] = '💬 ' . ($message !== '' ? esc_html($message) : '(пусто)'); + $lines[] = ''; + $lines[] = '⚙️ Попало в спам: ' . esc_html($ctx['reason']); + $lines[] = '📊 Классификация: ' . self::format_reasons($class['reasons']); + + $meta_bits = []; + if ($ctx['ip'] !== '') $meta_bits[] = 'IP ' . esc_html($ctx['ip']); + if ($ctx['user_agent'] !== '') $meta_bits[] = 'UA ' . esc_html(self::shorten_ua($ctx['user_agent'])); + if ($meta_bits) $lines[] = '🌍 ' . implode(' · ', $meta_bits); + + $utms = self::extract_utms($fields); + if ($utms !== '') $lines[] = '🏷 ' . $utms; + + $other = self::extract_other_fields($fields); + if ($other !== '') $lines[] = '📋 ' . $other; + + $text = implode("\n", $lines); + $text = self::truncate($text); + + $markup = null; + if ($phone !== null) { + $markup = [ + 'inline_keyboard' => [[ + ['text' => '📞 Позвонить: ' . $phone['pretty'], 'url' => 'tel:' . $phone['e164']], + ]], + ]; + } + + return ['text' => $text, 'reply_markup' => $markup]; + } + + private static function pick(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 flatten_fields_for_phone(array $fields): string + { + $out = []; + foreach ($fields as $k => $v) { + if (is_array($v)) $v = implode(' ', array_map('strval', $v)); + $out[] = (string)$v; + } + return implode("\n", $out); + } + + private static function format_reasons(array $reasons): string + { + if (!$reasons) return '—'; + $parts = []; + foreach ($reasons as $r) { + $parts[] = $r['sign'] . $r['weight'] . ' ' . $r['text']; + } + return esc_html(implode(', ', $parts)); + } + + private static function shorten_ua(string $ua): string + { + $ua = preg_replace('/\s+/', ' ', $ua) ?? $ua; + if (mb_strlen($ua) > 80) $ua = mb_substr($ua, 0, 80) . '…'; + return $ua; + } + + private static function extract_utms(array $fields): string + { + $keys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']; + $parts = []; + foreach ($keys as $k) { + if (!empty($fields[$k])) { + $v = is_array($fields[$k]) ? implode(' ', $fields[$k]) : (string)$fields[$k]; + $parts[] = esc_html($k) . '=' . esc_html($v); + } + } + return implode(' · ', $parts); + } + + private static function extract_other_fields(array $fields): string + { + $known = [ + 'your-name', 'name', 'имя', 'fio', + 'your-email', 'email', 'e-mail', 'mail', + 'your-tel', 'tel', 'phone', 'телефон', + 'your-message', 'message', 'comment', 'сообщение', 'вопрос', + 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', + '_wpcf7', '_wpcf7_version', '_wpcf7_locale', '_wpcf7_unit_tag', + '_wpcf7_container_post', '_wpcf7_posted_data_hash', 'g-recaptcha-response', + ]; + $parts = []; + foreach ($fields as $k => $v) { + if (in_array($k, $known, true)) continue; + if (strpos($k, 'wpcf7') === 0) continue; + if (is_array($v)) $v = implode(', ', array_map('strval', $v)); + $v = trim((string)$v); + if ($v === '') continue; + $parts[] = esc_html($k) . ': ' . esc_html($v); + } + return implode(' · ', $parts); + } + + private static function truncate(string $text): string + { + if (mb_strlen($text) <= self::TG_MAX_LEN) return $text; + $suffix = "\n…[обрезано]"; + $cut = self::TG_MAX_LEN - mb_strlen($suffix); + return mb_substr($text, 0, $cut) . $suffix; + } +} diff --git a/tests/MessageFormatterTest.php b/tests/MessageFormatterTest.php new file mode 100644 index 0000000..8eea6e3 --- /dev/null +++ b/tests/MessageFormatterTest.php @@ -0,0 +1,150 @@ + 'Анна Петровна', + '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('