Telegram webhook-контролер на Yii2: CSRF, Secret Token, ідемпотентність та Yii Queue

При розробці високонавантажених та відмовостійких Telegram-бот ів на фреймворку Yii2 вебхуки (Webhooks) є кращим способом отримання подій. Однак при промисловій експлуатації розробники часто стикаються з проблемами: блокування запитів механізмом CSRF-захисту Yii2, дублювання обробки повідомлень через повторні спроби Telegram та зависання вебхука при виконанні важких операцій.

У цій статті ми побудуємо архітектуру webhook-контролера, яка вирішує ці завдання: відключимо валідацію CSRF, налаштуємо автентифікацію вхідних запитів через X-Telegram-Bot-Api-Secret-Token, забезпечимо ідемпотентність за update_id і винесемо всю бізнес-логіку у фонову чергу yii2-queue.

1. Маршрутизація та відключення CSRF-валідації в Yii2

За замовчуванням Yii2 перевіряє наявність CSRF-токена в POST-запитах. Зрозуміло, що сервери Telegram не передають токен Yii2, тому без спеціального налаштування контролер поверне HTTP-помилку 400 Bad Request.

Для відключення перевірки достатньо встановити властивість public $enableCsrfValidation = false; безпосередньо в контролері вебхука. Також важливо явно задати формат відповіді Response::FORMAT_JSON, щоб сервер завжди віддавав валідну JSON-відповідь з коректними заголовками.

2. Захист ендпоїнту: перевірка X-Telegram-Bot-Api-Secret-Token

Оскільки ендпоїнт вебхука публічно доступний у мережі, будь-який зловмисник може надіслати на нього підроблений HTTP POST-запит. Щоб гарантувати, що запит надійшов саме від Telegram, використовується параметр secret_token при виклику методу setWebhook.

При кожному виклику Telegram передає цей рядок в HTTP-заголовку X-Telegram-Bot-Api-Secret-Token. Контролер має звірити отримане значення із секретом з конфігурації додатка (наприклад, Yii::$app->params['telegram_secret_token'] або змінних середовища getenv()). Якщо заголовка немає або він не збігається, запит негайно відхиляється з кодом 403 Forbidden до парсингу тіла запиту.

3. Ідемпотентність та запобігання дублям через update_id

Telegram очікує на відповідь HTTP 200 OK протягом кількох секунд. Якщо вебсервер відповідає довше (через мережеві затримки, збої або таймаути), Telegram вважає доставку невдалою і повторює відправку того самого update_id з експоненціальною затримкою.

Без механізму дедуплікації це призводить до повторного запису даних у БД, дублювання замовлень або багаторазового надсилання повідомлень користувачеві. Рішення — зберігати вхідний update_id у базу даних (або атомарно в Redis) за допомогою унікального індексу перед відправкою завдання в чергу. Якщо update_id вже присутній у системі, контролер одразу повертає 200 OK без повторного створення завдання.

4. Швидка відповідь та винесення логіки в yii2-queue

Webhook-контролер не повинен виконувати важкі завдання: звертатися до зовнішніх CRM, генерувати PDF або робити складні SQL-запити. Єдине завдання actionWebhook — прийняти JSON, перевірити секрет, зафіксувати update_id, поставити завдання в чергу і повернути HTTP 200 OK за 10–50 мілісекунд.

Усю бізнес-логіку бере на себе компонент yii2-queue (який використовує DB, Redis або RabbitMQ як драйвер). Це повністю виключає таймаути з боку Telegram API.

5. Реалізація Webhook-контролера в Yii2

Нижче наведено робочий код контролера TelegramWebhookController.php. Токен бота та секрет вебхука зчитуються з конфігурації додатка.

<?php

namespace app\controllers;

use Yii;
use yii\web\Controller;
use yii\web\Response;
use yii\web\ForbiddenHttpException;
use app\jobs\TelegramUpdateJob;

class TelegramWebhookController extends Controller
{
/**
* Отключаем CSRF-валидацию для приема вебхуков от Telegram
*/
public $enableCsrfValidation = false;

public function actionIndex()
{
Yii::$app->response->format = Response::FORMAT_JSON;

// 1. Проверяем secret_token из заголовка
$secretHeader = Yii::$app->request->getHeaders()->get('X-Telegram-Bot-Api-Secret-Token');
$expectedSecret = Yii::$app->params['telegram_secret_token'] ?? getenv('TELEGRAM_SECRET_TOKEN');

if (empty($expectedSecret) || $secretHeader !== $expectedSecret) {
Yii::warning('Недействительный Secret Token в Telegram Webhook', 'telegram');
throw new ForbiddenHttpException('Invalid secret token');
}

// 2. Получаем и декодируем RAW JSON
$rawBody = Yii::$app->request->getRawBody();
$update = json_decode($rawBody, true);

if (json_last_error() !== JSON_ERROR_NONE || !isset($update['update_id'])) {
return ['status' => 'error', 'message' => 'Invalid JSON payload'];
}

$updateId = (int)$update['update_id'];

// 3. Проверка идемпотентности через БД
$db = Yii::$app->db;
$exists = $db->createCommand(
'SELECT 1 FROM telegram_processed_updates WHERE update_id = :id',
[':id' => $updateId]
)->queryScalar();

if ($exists) {
// Игнорируем дубликат, отдаем 200 OK
return ['status' => 'ok', 'message' => 'Already processed'];
}

// Регистрируем update_id в таблице обработанных событий
$db->createCommand()->insert('telegram_processed_updates', [
'update_id' => $updateId,
'created_at' => date('Y-m-d H:i:s'),
])->execute();

// 4. Отправляем апдейт в очередь yii2-queue
Yii::$app->queue->push(new TelegramUpdateJob([
'update' => $update,
]));

return ['status' => 'ok'];
}
}

6. Обробка вхідних подій у фоновому завданні Queue Job

Нижче наведено клас TelegramUpdateJob.php, який виконується воркером черги. Тут виконується розбір команд, збереження ліда в БД без використання сесій ($_SESSION заборонений у консольному контексті черги) та надсилання відповідей через cURL з перевіркою помилок і статусів відповіді Telegram Bot API.

<?php

namespace app\jobs;

use Yii;
use yii\base\BaseObject;
use yii\queue\JobInterface;

class TelegramUpdateJob extends BaseObject implements JobInterface
{
public array $update;

public function execute($queue)
{
if (isset($this->update['message'])) {
$this->handleMessage($this->update['message']);
} elseif (isset($this->update['callback_query'])) {
$this->handleCallbackQuery($this->update['callback_query']);
}
}

private function handleMessage(array $message): void
{
$chatId = $message['chat']['id'] ?? null;
$text = trim($message['text'] ?? '');

if (!$chatId) {
return;
}

if (str_starts_with($text, '/start')) {
// Генерация случайного идентификатора лида
$leadId = bin2hex(random_bytes(7));

// Сохраняем заявку в БД
Yii::$app->db->createCommand()->insert('leads', [
'lead_id' => $leadId,
'telegram_chat_id' => $chatId,
'status' => 'new',
'created_at' => date('Y-m-d H:i:s'),
])->execute();

// callback_data не должен превышать 64 байта
$keyboard = [
'inline_keyboard' => [[
['text' => 'Подтвердить заявку', 'callback_data' => 'cnf_' . $leadId]
]]
];

$this->sendTelegramRequest('sendMessage', [
'chat_id' => $chatId,
'text' => "Заявка №{$leadId} создана. Нажмите кнопку для подтверждения.",
'reply_markup' => json_encode($keyboard),
]);
}
}

private function handleCallbackQuery(array $callbackQuery): void
{
$callbackId = $callbackQuery['id'];
$chatId = $callbackQuery['message']['chat']['id'] ?? null;
$data = $callbackQuery['data'] ?? '';

if (str_starts_with($data, 'cnf_')) {
$leadId = substr($data, 4);

Yii::$app->db->createCommand()->update('leads',
['status' => 'confirmed'],
'lead_id = :lid',
[':lid' => $leadId]
)->execute();

// Обязательный ответ на Callback Query
$this->sendTelegramRequest('answerCallbackQuery', [
'callback_query_id' => $callbackId,
'text' => 'Заявка подтверждена!',
]);

if ($chatId) {
$this->sendTelegramRequest('sendMessage', [
'chat_id' => $chatId,
'text' => "Статус заявки №{$leadId} изменен на "Подтверждена".",
]);
}
}
}

/**
* Метод отправки запросов к Telegram Bot API через cURL
*/
private function sendTelegramRequest(string $method, array $params): array
{
$botToken = Yii::$app->params['telegram_bot_token'] ?? getenv('TELEGRAM_BOT_TOKEN');
$url = "https://api.telegram.org/bot{$botToken}/{$method}";

$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);

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

if ($response === false) {
Yii::error("Telegram cURL Error: {$curlError}", 'telegram');
return ['ok' => false];
}

$result = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Yii::error("Telegram invalid JSON: {$response}", 'telegram');
return ['ok' => false];
}

if ($httpCode !== 200 || !($result['ok'] ?? false)) {
Yii::warning("Telegram API Error [{$httpCode}]: " . json_encode($result), 'telegram');
}

return $result;
}
}

7. Таблиця для зберігання оброблених update_id

Для коректної роботи перевірки ідемпотентності створіть таблицю у вашій базі даних через миграцію Yii2:

CREATE TABLE `telegram_processed_updates` (
`update_id` BIGINT NOT NULL,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`update_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Рекомендується налаштувати періодичне очищення цієї таблиці консольною командою Yii2 (наприклад, видаляти записи, старші за 3–7 днів), оскільки Telegram не повторить спробу через такий тривалий час.

Підсумки

Описана схема гарантує високий рівень безпеки та відмовостійкості вебхуків на Yii2. Секретный токен захищає від сторонніх викликів, база даних запобігає повторній обробці через дублюючі запити Telegram, а yii2-queue гарантує швидку HTTP-відповідь 200 OK без зависань вебсервера.

Якщо вам потрібна професійна розробка високонавантажених Telegram-ботів або інтеграція Mini Apps з вашою інфраструктурою, зверніться до фахівців BotCreator.

Нові статті — у Telegram

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