Files
cf7-spam-to-telegram/includes/class-message-formatter.php
T
Vladimir Bryzgalov 2de8f0be57 fix(formatter+telegram): drop tel: inline button, mark all 4xx as permanent
Pilot on washanyanya.ru exposed that Telegram Bot API rejects tel:
URLs in inline_keyboard with HTTP 400 "Wrong port number specified
in the URL". Queue was stuck on 5 items retrying the same failure
4 times each.

- MessageFormatter: always returns reply_markup = null. The phone
  number is already rendered in the '📱 ...' line, and mobile
  Telegram clients linkify it automatically.
- TelegramClient::is_permanent: treat all 4xx except 429 as permanent
  client-side errors, so future payload-shape bugs surface in the
  dashboard widget immediately instead of burning five retry cycles.
- MessageFormatterTest updated: the old test asserting tel: in
  reply_markup is replaced with one asserting reply_markup is null
  and the phone still appears in the text body.
- Spec §9 + §19 updated, §19's open question closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:55:06 +05:00

164 lines
6.2 KiB
PHP

<?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);
// Telegram Bot API rejects tel: URLs in inline_keyboard with HTTP 400
// ("Wrong port number specified"), so no inline button is emitted.
// The phone number is already shown in the message body (line '📱 ...')
// and mobile Telegram clients make such numbers tappable automatically.
return ['text' => $text, 'reply_markup' => null];
}
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;
}
}