Deep Links в Telegram Bot API: лимиты start-payload, маппинг в БД и защита от подмены

Передача контекста при переходе пользователя в Telegram-бота — стандартная задача для CRM, интернет-магазинов и маркетинговых кампаний. Механизм Deep Linking позволяет передать в бота параметр через URL формата https://t.me/YourBot?start=payload. Когда пользователь нажимает кнопку «Запустить», Telegram отправляет вашему серверу входящее сообщение с текстом /start payload.

Однако диплинки часто путают с системой управления состояниями диалога (FSM). Параметр start срабатывает единоразово в момент отправки команды запуска и служит лишь точкой входа, передающей внешний контекст (ID реферала, код акции, ID заявки из CRM). В этой статье мы разберём ограничения протокола, способы предотвращения спуфинга параметров и продакшен-реализацию обработки диплинков на PHP.

Механика Deep Linking в Telegram Bot API и жесткие лимиты

Telegram накладывает строго регламентированные технические ограничения на строковый параметр start:

  • Допустимые символы: латинские буквы A-Z, a-z, цифры 0-9, а также символы подчеркивания _ и дефиса -. Символы вроде =, ?, &, % или кириллица запрещены.
  • Максимальная длина: ровно 64 байта.
  • Специфика вызова: если пользователь уже запускал бота ранее, повторный клик по ссылке t.me/YourBot?start=payload откроет диалог с ботом, но кнопка «Запустить» внизу чата появится снова. Сообщение /start payload будет отправлено на вебхук только после физического клика по этой кнопке.

Главная ошибка неопытных разработчиков — попытка поместить в payload некодированные JSON-объекты или связки ключей вида lead_123_user_456_promo_winter. Такой подход быстро упирается в лимит 64 байт и провоцирует уязвимости безопасности.

Секьюрно передаем контекст: генерация и маппинг токенов в БД

Передавать открытые идентификаторы сущностей из базы данных (например, ?start=order_1540) крайне опасно. Любой пользователь может вручную заменить число в URL и запустить бизнес-логику для чужого заказа. Также нельзя полагаться на сессии ($_SESSION), так как переход происходит из внешнего приложения Telegram, где куки веб-браузера недоступны.

Безопасный подход состоит в использовании одноразовых или временных криптографически стойких токенов. Токен генерируется на стороне бэкенда, сохраняется в БД с привязкой к целевой сущности, после чего формируется итоговая ссылка.

<?php

declare(strict_types=1);

function createLeadDeepLink(PDO $pdo, string $botUsername, string $entityType, int $entityId): string
{
// Генерируем 7 случайных байт и переводим в hex (ровно 14 символов, [a-f0-9])
$leadToken = bin2hex(random_bytes(7));

$stmt = $pdo->prepare('
INSERT INTO bot_deep_links (token, entity_type, entity_id, created_at, is_used)
VALUES (:token, :type, :id, NOW(), 0)
');

$stmt->execute([
':token' => $leadToken,
':type' => $entityType,
':id' => $entityId,
]);

return "https://t.me/{$botUsername}?start={$leadToken}";
}

Длина полученного токена — 14 байт, что полностью укладывается в лимит 64 байт. Даже если злоумышленник попытается перебрать значения (brute-force), энтропия в 56 бит сделает атаку нереализуемой.

Обработка Webhook: проверка Secret Token и защита от дубликатов

При получении запроса от Telegram необходимо решить две задачи сетевой безопасности и надежности:

  1. Аутентификация источника: валидация заголовка X-Telegram-Bot-Api-Secret-Token, заданного при вызове setWebhook.
  2. Идемпотентность: исключение повторной обработки одного и того же события при сбоях сети (Telegram повторно отправляет Update, если бэкенд не ответил HTTP-кодом 200 вовремя). Дедупликация выполняется по update_id.
<?php

declare(strict_types=1);

$secretToken = getenv('TELEGRAM_WEBHOOK_SECRET') ?: '';
$botToken = getenv('TELEGRAM_BOT_TOKEN') ?: '';

// 1. Валидация Secret Token
$incomingSecret = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '';
if ($secretToken !== '' && !hash_equals($secretToken, $incomingSecret)) {
http_response_code(403);
echo json_encode(['error' => 'Access denied']);
exit;
}

$rawInput = file_get_contents('php://input');
if (!$rawInput) {
http_response_code(400);
exit;
}

$update = json_decode($rawInput, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($update)) {
http_response_code(400);
exit;
}

$pdo = new PDO('mysql:host=localhost;dbname=app_db;charset=utf8mb4', 'db_user', 'db_pass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

// 2. Проверка идемпотентности по update_id
$updateId = (int)($update['update_id'] ?? 0);
if ($updateId > 0) {
$stmt = $pdo->prepare('INSERT IGNORE INTO processed_updates (update_id, created_at) VALUES (:id, NOW())');
$stmt->execute([':id' => $updateId]);

if ($stmt->rowCount() === 0) {
// Уведомление уже обрабатывалось ранее
echo json_encode(['ok' => true, 'description' => 'Duplicate update ignored']);
exit;
}
}

Парсинг команды /start и исполнение бизнес-логики

Когда валидация запроса завершена, извлекаем текст сообщения. Сообщение от Telegram с диплинком содержит text вида /start 4a8b1c9d0e2f3a. Нам нужно отделить команду /start от самого payload и сопоставить его с записью в базе данных.

<?php

$message = $update['message'] ?? null;
$text = trim($message['text'] ?? '');
$chatId = $message['chat']['id'] ?? null;

if ($chatId && strpos($text, '/start') === 0) {
$parts = explode(' ', $text, 2);
$payload = isset($parts[1]) ? trim($parts[1]) : null;

handleStartCommand($pdo, $botToken, (int)$chatId, $payload);
}

echo json_encode(['ok' => true]);

function handleStartCommand(PDO $pdo, string $botToken, int $chatId, ?string $payload): void
{
if ($payload === null || $payload === '') {
$replyText = "Добро пожаловать! Вы запустили бота напрямую.";
sendTelegramMessage($botToken, $chatId, $replyText);
return;
}

// Проверяем соответствие payload регулярному выражению лимита Telegram
if (!preg_match('/^[a-zA-Z0-9_-]{1,64}$/', $payload)) {
sendTelegramMessage($botToken, $chatId, "Ошибка: недействительный формат ссылки.");
return;
}

// Ищем токен в БД
$stmt = $pdo->prepare('
SELECT id, entity_type, entity_id, is_used
FROM bot_deep_links
WHERE token = :token
LIMIT 1
');
$stmt->execute([':token' => $payload]);
$linkData = $stmt->fetch();

if (!$linkData) {
sendTelegramMessage($botToken, $chatId, "Ссылка не найдена или ее срок действия истек.");
return;
}

// Пример обработки сущности (например, привязка лида к CRM)
$entityType = $linkData['entity_type'];
$entityId = (int)$linkData['entity_id'];

// Делаем токен использованным (если диплинк одноразовый)
$updateStmt = $pdo->prepare('UPDATE bot_deep_links SET is_used = 1 WHERE id = :id');
$updateStmt->execute([':id' => $linkData['id']]);

$replyText = "Контекст успешно принят! Тип: <strong>{$entityType}</strong>, ID: <strong>{$entityId}</strong>.";
sendTelegramMessage($botToken, $chatId, $replyText);
}

Взаимодействие с Telegram Bot API через cURL

Для отправки сообщений в Telegram не следует применять функцию file_get_contents, так как она не обеспечивает гибкой настройки таймаутов и корректной обработки сетевых ошибок. Используем cURL с явной проверкой HTTP-кодов ответа и структуры JSON-ответа Telegram API.

<?php

declare(strict_types=1);

function sendTelegramMessage(string $botToken, int $chatId, string $text): array
{
$url = "https://api.telegram.org/bot{$botToken}/sendMessage";

$payload = [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
'disable_web_page_preview' => true,
];

$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
]);

$rawResponse = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);

if ($rawResponse === false) {
throw new RuntimeException("cURL Error: {$curlError}");
}

if ($httpCode !== 200) {
throw new RuntimeException("Telegram API returned HTTP code {$httpCode}: {$rawResponse}");
}

$response = json_decode((string)$rawResponse, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException("Failed to decode Telegram JSON response");
}

if (empty($response['ok'])) {
$code = $response['error_code'] ?? 'unknown';
$desc = $response['description'] ?? 'no description';
throw new RuntimeException("Telegram API Error [{$code}]: {$desc}");
}

return $response;
}

Сравнение подходов: Token Mapping vs Signed Payload (HMAC)

Существует два основных архитектурных паттерна работы с start=payload:

1. Маппинг через случайный токен в БД (Stateful)

Сервер генерирует токен (например, через bin2hex(random_bytes(7))) и записывает связку в таблицу. При обработке вебхука происходит SQL-запрос.

  • Плюсы: полная безопасность (в ссылке нет реальных ID), возможность аннулировать ссылку в любой момент, можно отследить статус is_used.
  • Минусы: требуется запрос к БД при каждом клике.

2. HMAC-подпись данных (Stateless)

Если запись в БД нежелательна, параметры упаковываются и подписываются секретным ключом бэкенда: payload = base64url(data) + '.' + hmac_sha256(data, secret). Из-за лимита в 64 байта подпись приходится урезать (например, до 8–12 символов).

  • Плюсы: не требуется база данных для проверки подлинности.
  • Минусы: жесткие рамки 64 байт сильно ограничивают объем полезной нагрузки; нельзя запретить повторное использование ссылки без таблицы использованных подписей.

Для большинства коммерческих задач (авторизация, привязка заказа, сквозная аналитика лидов) вариант с сохранением короткого токена в БД оказывается более гибким и надёжным.

Если вам требуется профессиональная интеграция Telegram с CRM и сервисами компании, команда BotCreator разработает отказоустойчивое решение под ключ.

Новые статьи — в Telegram

Разбираем, что автоматизировать в бизнесе и как это работает на практике. Без спама.