Deep linking t.me/Bot?start=payload: лимит, маппинг в БД и защита от подмены

Параметр start в deep‑link t.me/Bot?start=payload позволяет передать бота произвольную строку при первом запуске диалога. Согласно документации Bot API, длина payload ограничена 64 байтами, а значение доступно только в объекте message с командой /start, когда пользователь ещё не имел чата с ботом. Если чат уже существует, бот получит обычное сообщение /start без параметра — это важное отличие от продолжения диалога.

Генерация безопасного payload

Для привязки payload к сущности (например, ID записи на услугу) не стоит сохранять сам ID открытым: его можно подменить. Лучше использовать криптостойкий одноразовый токен, а в базе хранить только его хеш. Пример генерации ссылки в Laravel:

use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;

function makeDeepLink(int $entityId, int $ttlSeconds = 300): string
{
$botUsername = getenv('TELEGRAM_BOT_USERNAME');
$raw = Str::random(7); // 7 байт → base64url ≈ 11 символов
$token = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
$hash = hash('sha256', $token);
$expires = now()->addSeconds($ttlSeconds);

DB::table('deep_link_tokens')->insert([
'token_hash' => $hash,
'entity_id' => $entityId,
'expires_at' => $expires,
'used' => false,
]);

$payload = $token; // уже безопасен для URL
return "https://t.me/{$botUsername}?start={$payload}";
}

Токен длиной 11 символов хорошо укладывается в лимит 64 байта. Мы сохраняем только его SHA‑256 хеш, поэтому даже при утечке базы злоумышленник не сможет подобрать оригинальный токен без перебора.

Обработка start‑команды в вебхуке

При получении обновления через вебхук необходимо:

  1. Проверить заголовок X-Telegram-Bot-Api-Secret-Token (если вебхук зарегистрирован с secret_token).
  2. Извлечь update_id и обеспечить идемпотентность — не обрабатывать одно и то же обновление дважды.
  3. Если сообщение содержит команду /start с параметром, взять payload, найти соответствующий хеш в таблице deep_link_tokens, проверить срок жизни и флаг used.
  4. При успешной проверке пометить токен как использованный, получить entity_id и отправить персонализированное сообщение через Bot API, используя cURL.
Пример обработчика на чистом PHP (может быть адаптирован под Laravel контроллер):
header('Content-Type: application/json');

$secretToken = getenv('TELEGRAM_WEBHOOK_SECRET');
if ($secretToken && $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] !== $secretToken) {
http_response_code(403);
exit;
}

$input = file_get_contents('php://input');
$update = json_decode($input, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
exit;
}

$updateId = $update['update_id'] ?? null;
if ($updateId === null) {
http_response_code(200);
exit;
}

// Идемпотентность: сохраняем обработанные update_id в Redis (пример)
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
if ($redis->get('tg_update:' . $updateId)) {
http_response_code(200);
exit; // уже обработано
}
$redis->setex('tg_update:' . $updateId, 300, '1');

$message = $update['message'] ?? null;
if (!$message || !isset($message['text'])) {
http_response_code(200);
exit;
}

$text = trim($message['text']);
if (str_starts_with($text, '/start')) {
$parts = explode(' ', $text, 2);
$payload = $parts[1] ?? '';
if ($payload === '') {
// обычный /start без параметра
http_response_code(200);
exit;
}

// проверяем длину payload (ограничение Bot API)
if (strlen($payload) > 64) {
http_response_code(200);
exit;
}

// ищем токен в БД
$pdo = new PDO('mysql:host=' . getenv('DB_HOST') . ';dbname=' . getenv('DB_NAME'),
getenv('DB_USER'), getenv('DB_PASS'));
$stmt = $pdo->prepare('SELECT id, entity_id, expires_at, used FROM deep_link_tokens WHERE token_hash = ?');
$hash = hash('sha256', $payload);
$stmt->execute([$hash]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$row) {
// токен не найден или уже использован
http_response_code(200);
exit;
}
if (new DateTime($row['expires_at']) < new DateTime()) {
// срок истёк
http_response_code(200);
exit;
}
if ((int)$row['used'] === 1) {
// уже использован
http_response_code(200);
exit;
}

// помечаем как использованный
$upd = $pdo->prepare('UPDATE deep_link_tokens SET used = 1 WHERE id = ?');
$upd->execute([$row['id']]);

$entityId = (int)$row['entity_id'];
$chatId = $message['chat']['id'];

// Формируем персонализированное сообщение
$reply = "Привет! Вы перешли по ссылке для сущности #{$entityId}.";

// Отправляем через Bot API cURL
$botToken = getenv('TELEGRAM_BOT_TOKEN');
$apiUrl = "https://api.telegram.org/bot{$botToken}/sendMessage";
$data = [
'chat_id' => $chatId,
'text' => $reply,
];

$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $apiUrl,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($data),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode !== 200) {
// логируем ошибку, но не прерываем вебхук
error_log("Telegram sendMessage failed: {$httpCode} {$response}");
} else {
$decoded = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE || !$decoded['ok'] ?? false) {
error_log("Bad Telegram response: {$response}");
}
}
curl_close($ch);
}

http_response_code(200);

Отличие от продолжения диалога и идемпотентность

Параметр start доступен только тогда, когда пользователь впервые открывает чат с ботом через deep‑link. Если пользователь уже имел диалог и отправляет /start (или бот получает команду из меню), объект message будет содержать только команду без параметра. Поэтому логика, привязанная к payload, должна выполняться только при первом запуске.

Для защиты от повторной обработки одного и того же обновления используем идемпотентность: сохраняем update_id в быстром хранилище (Redis, Memcached или отдельная таблица с TTL). При повторном поступлении того же обновления мы просто возвращаем 200 OK без действий.

Безопасность и защита от подмены payload

  • Пayload никогда не хранится открыто в базе — сохраняем только его криптостойкий хеш (SHA‑256).
  • Токен генерируется функцией random_bytes, что делает его непредсказуемым.
  • Устанавливаем короткий TTL (например, 5 минут) и флаг used, чтобы токен нельзя было использовать повторно после активации.
  • Проверяем длину payload — не более 64 байт, иначе отклоняем запрос.
  • Если вебхук зарегистрирован с secret_token, обязательно проверяем заголовок X-Telegram-Bot-Api-Secret-Token.

Таким образом, даже если злоумышленник перехватит или подменит параметр start, он не сможет подобрать валидный токен без доступа к секретному ключу, используемому при генерации хеша, и не сможет повторно использовать уже погашенный токен.

Помните: deep link — это лишь точка входа. Вся бизнес‑логика (валидация, запись в CRM, начисление бонусов) должна находиться в защищённом серверном обработчике, а не в клиентской части.

Для быстрого старта вы можете использовать готовый пакет BotCreator, который предоставляет шаблоны вебхуков и утилиты для работы с deep‑link в Laravel и чистом PHP.

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

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