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>
This commit is contained in:
Vladimir Bryzgalov
2026-04-17 23:55:06 +05:00
parent 1f89052f72
commit 2de8f0be57
5 changed files with 38 additions and 45 deletions
@@ -978,8 +978,12 @@ final class MessageFormatterTest extends TestCase
self::assertStringContainsString('Akismet', $result['text']);
}
public function test_tel_button_present_when_phone_valid(): void
public function test_reply_markup_is_null_because_tg_rejects_tel_urls(): void
{
// Telegram Bot API rejects tel: URLs in inline_keyboard with
// HTTP 400 "Wrong port number specified in the URL". We no longer
// emit any inline button; the phone number is rendered in the
// message body instead and mobile TG clients auto-linkify it.
$fields = ['your-tel' => '+79031234567', 'your-name' => 'Анна'];
$classification = Classifier::classify($fields, ['reason' => 'Akismet']);
$r = MessageFormatter::build([
@@ -989,9 +993,9 @@ final class MessageFormatterTest extends TestCase
'ip' => '', 'user_agent' => '', 'is_retro' => false,
]);
self::assertNotNull($r['reply_markup']);
$json = json_encode($r['reply_markup']);
self::assertStringContainsString('tel:+79031234567', $json);
self::assertNull($r['reply_markup']);
// Phone must still be present in the text, formatted as Russian pretty.
self::assertStringContainsString('+7 (903) 123-45-67', $r['text']);
}
public function test_no_tel_button_when_no_phone(): void
@@ -1174,16 +1178,11 @@ final class MessageFormatter
$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];
// 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
@@ -1766,13 +1765,11 @@ final class TelegramClient
private static function is_permanent(int $code, string $desc): bool
{
if ($code === 401 || $code === 403) return true;
if ($code === 400) {
$d = strtolower($desc);
if (strpos($d, 'chat not found') !== false) return true;
if (strpos($d, 'bot was blocked') !== false) return true;
if (strpos($d, 'user is deactivated') !== false) return true;
}
// 4xx (except 429 rate-limit) are client-side errors: bad request,
// invalid chat, revoked token, malformed payload — retrying will not
// help. Mark as permanent so the queue gives up immediately and the
// admin is notified via the dashboard widget.
if ($code >= 400 && $code < 500 && $code !== 429) return true;
return false;
}
}