При разработке высоконагруженных и отказоустойчивых 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.