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

Розбираємо, що автоматизувати в бізнесі та як це працює на практиці. Без спаму.