feat(message-formatter): build Telegram HTML payload with inline tel button
This commit is contained in:
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Cf7stg;
|
||||||
|
|
||||||
|
final class MessageFormatter
|
||||||
|
{
|
||||||
|
private const TG_MAX_LEN = 4096;
|
||||||
|
|
||||||
|
private const LABEL_MAP = [
|
||||||
|
'client' => ['emoji' => '🟢', 'text' => 'похоже клиент'],
|
||||||
|
'unclear' => ['emoji' => '🟡', 'text' => 'неясно'],
|
||||||
|
'spam' => ['emoji' => '🔴', 'text' => 'похоже спам'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* fields:array<string,mixed>,
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Cf7stg\Tests;
|
||||||
|
|
||||||
|
use Cf7stg\MessageFormatter;
|
||||||
|
use Cf7stg\Classifier;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class MessageFormatterTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_full_client_payload(): void
|
||||||
|
{
|
||||||
|
$fields = [
|
||||||
|
'your-name' => 'Анна Петровна',
|
||||||
|
'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' => '<script>alert(1)</script>', '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('<script>', $r['text']);
|
||||||
|
self::assertStringContainsString('<script>', $r['text']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_long_message_is_truncated(): void
|
||||||
|
{
|
||||||
|
$long = str_repeat('А', 5000);
|
||||||
|
$fields = ['your-message' => $long, '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::assertLessThanOrEqual(4096, mb_strlen($r['text']));
|
||||||
|
self::assertStringContainsString('…[обрезано]', $r['text']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retro_prefix_added(): void
|
||||||
|
{
|
||||||
|
$fields = ['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' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('📂 Ретроспектива', $r['text']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_spam_label_emoji(): void
|
||||||
|
{
|
||||||
|
$fields = [
|
||||||
|
'your-name' => 'Melissa',
|
||||||
|
'your-message' => 'SEO promotion, visit http://spam.net',
|
||||||
|
];
|
||||||
|
$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::assertStringContainsString('🔴 похоже спам', $r['text']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unclear_label_emoji(): void
|
||||||
|
{
|
||||||
|
$fields = ['your-name' => 'Олег', 'your-message' => 'Здравствуйте'];
|
||||||
|
$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::assertStringContainsString('🟡 неясно', $r['text']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user