Разработка отказоустойчивого Telegram Webhook на Yii2: обход CSRF, защита Secret Token и Yii Queue

При разработке высоконагруженных Telegram-ботов на Yii2 стандартный подход с обработкой обновлений «на лету» быстро упирается в ограничения платформы. Telegram ожидает от вашего сервера быстрый ответ HTTP 200 OK (желательно в пределах 1–2 секунд). Если ваш код начинает выполнять «тяжелые» операции — запросы к сторонним API, генерацию изображений или сложные транзакции в БД — таймаут превышается. Telegram расценивает это как сбой доставки и начинает повторно отправлять те же самые события (updates), что лавинообразно увеличивает нагрузку и приводит к дублированию действий.

Архитектурный паттерн: Быстрый прием и фоновая обработка

Единственный надежный способ спроектировать вебхук — разделить прием сообщений и их бизнес-обработку. Схема выглядит так:

  1. Контроллер принимает POST-запрос от Telegram.
  2. Проверяется подпись запроса (Secret Token) для защиты от спама.
  3. Выполняется проверка на дубликаты (идемпотентность) по уникальному update_id.
  4. Сырой JSON сохраняется в буферную таблицу БД со статусом pending.
  5. В очередь (Yii Queue) отправляется легковесная задача на обработку.
  6. Контроллер мгновенно возвращает ответ HTTP 200 OK.
  7. Фоновый воркер асинхронно обрабатывает задачу из очереди.

Шаг 1. Создание таблицы для входящих обновлений

Для обеспечения идемпотентности и логирования всех входящих пакетов нам понадобится таблица в БД. Поле update_id, предоставляемое Telegram, идеально подходит на роль первичного ключа. Это гарантирует, что на уровне СУБД мы никогда не запишем одно и то же событие дважды.

Создадим миграцию Yii2:

use yii\db\Migration;

class m240101_000000_create_telegram_update_table extends Migration
{
public function safeUp()
{
$this->createTable('{{%telegram_update}}', [
'id' => $this->bigInteger()->notNull(), // update_id от Telegram
'payload' => $this->text()->notNull(),
'status' => $this->string(32)->notNull()->defaultValue('pending'),
'created_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP'),
'updated_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
]);

$this->addPrimaryKey('pk-telegram_update-id', '{{%telegram_update}}', 'id');
$this->createIndex('idx-telegram_update-status', '{{%telegram_update}}', 'status');
}

public function safeDown()
{
$this->dropTable('{{%telegram_update}}');
}
}

Шаг 2. Обход CSRF и валидация X-Telegram-Bot-Api-Secret-Token

По умолчанию Yii2 защищает все POST-запросы с помощью механизма CSRF. Запросы от Telegram приходят извне, поэтому CSRF-валидацию для вебхука необходимо отключить. Делается это объявлением свойства $enableCsrfValidation = false в контроллере.

Для защиты роута от посторонних запросов мы обязаны использовать параметр secret_token при регистрации вебхука через метод setWebhook. Telegram будет передавать этот токен в заголовке X-Telegram-Bot-Api-Secret-Token. Наш контроллер должен сравнивать его со значением из конфигурации.

Реализуем WebhookController:

namespace app\controllers;

use Yii;
use yii\web\Controller;
use yii\web\BadRequestHttpException;
use yii\web\Response;
use app\models\TelegramUpdate;
use app\jobs\TelegramProcessJob;

class WebhookController extends Controller
{
// Отключаем встроенную CSRF-защиту Yii2
public $enableCsrfValidation = false;

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

// 1. Валидация секретного токена
$expectedToken = Yii::$app->params['telegram_webhook_secret_token'] ?? null;
$receivedToken = Yii::$app->request->headers->get('X-Telegram-Bot-Api-Secret-Token');

if (empty($expectedToken) || $receivedToken !== $expectedToken) {
throw new BadRequestHttpException('Access denied. Invalid secret token.');
}

// 2. Чтение и парсинг тела запроса
$rawBody = Yii::$app->request->getRawBody();
$update = json_decode($rawBody, true);

if (json_last_error() !== JSON_ERROR_NONE || !isset($update['update_id'])) {
throw new BadRequestHttpException('Invalid JSON payload.');
}

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

// 3. Обеспечение идемпотентности через транзакцию БД
$transaction = Yii::$app->db->beginTransaction();
try {
$exists = TelegramUpdate::find()->where(['id' => $updateId])->exists();
if ($exists) {
$transaction->rollBack();
// Возвращаем 200 OK, так как этот апдейт уже сохранен/обрабатывается
return ['status' => 'duplicate', 'update_id' => $updateId];
}

$dbUpdate = new TelegramUpdate();
$dbUpdate->id = $updateId;
$dbUpdate->payload = $rawBody;
$dbUpdate->status = 'pending';

if (!$dbUpdate->save()) {
throw new \Exception('Failed to save update to database.');
}

$transaction->commit();
} catch (\Exception $e) {
$transaction->rollBack();
Yii::error('Webhook DB error: ' . $e->getMessage(), 'telegram');
// Возвращаем HTTP 200, чтобы избежать бесконечного спама повторами от Telegram
return ['status' => 'db_error', 'message' => $e->getMessage()];
}

// 4. Постановка задачи в очередь Yii Queue
Yii::$app->queue->push(new TelegramProcessJob([
'updateId' => $updateId,
]));

return ['status' => 'accepted', 'update_id' => $updateId];
}
}

Шаг 3. Асинхронный воркер на Yii Queue

Для работы очередей в Yii2 обычно используется расширение yiisoft/yii2-queue. Наш джоб (Job) должен извлечь сырой payload из базы данных по полученному updateId, выполнить всю цепочку бизнес-логики, отправить ответ пользователю через Telegram Bot API и обновить статус записи на processed.

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

namespace app\jobs;

use Yii;
use yii\base\BaseObject;
use yii\queue\JobInterface;
use app\models\TelegramUpdate;

class TelegramProcessJob extends BaseObject implements JobInterface
{
/** @var int */
public $updateId;

public function execute($queue)
{
$dbUpdate = TelegramUpdate::findOne($this->updateId);
if (!$dbUpdate || $dbUpdate->status !== 'pending') {
return;
}

$payload = json_decode($dbUpdate->payload, true);
if (!$payload) {
$dbUpdate->status = 'failed';
$dbUpdate->save(false);
return;
}

try {
$this->processPayload($payload);

$dbUpdate->status = 'processed';
$dbUpdate->save(false);
} catch (\Exception $e) {
Yii::error("Failed processing update {$this->updateId}: " . $e->getMessage(), 'telegram');
$dbUpdate->status = 'failed';
$dbUpdate->save(false);

// Выбрасываем исключение дальше, чтобы очередь могла повторить попытку позже
throw $e;
}
}

protected function processPayload(array $payload)
{
// Пример обработки текстового сообщения
if (isset($payload['message']['chat']['id']) && isset($payload['message']['text'])) {
$chatId = $payload['message']['chat']['id'];
$text = trim($payload['message']['text']);

if ($text === '/start') {
$this->sendTelegramRequest('sendMessage', [
'chat_id' => $chatId,
'text' => "Добро пожаловать! Ваш запрос отправлен в обработку.",
]);
}
}
}

protected function sendTelegramRequest(string $method, array $params)
{
$token = Yii::$app->params['telegram_bot_token'] ?? null;
if (!$token) {
throw new \Exception('Telegram bot token is not configured.');
}

$url = "https://api.telegram.org/bot{$token}/{$method}";
$ch = curl_init();

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

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

if ($error) {
throw new \Exception("cURL Error: {$error}");
}

if ($httpCode !== 200) {
throw new \Exception("Telegram API returned HTTP Code {$httpCode}. Response: {$response}");
}

$result = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('Telegram response is not valid JSON.');
}

if (!($result['ok'] ?? false)) {
$description = $result['description'] ?? 'No description';
throw new \Exception("Telegram API error: {$description}");
}

return $result;
}
}

Рекомендации по эксплуатации и логированию

При использовании очередей важно правильно настроить мониторинг. Если воркер падает по ошибке (например, таймаут внешней интеграции), задача должна возвращаться в очередь с задержкой (retry delay). Настройте лимит попыток (ttr) в конфигурации консольного компонента очередей Yii2, чтобы избежать бесконечного зацикливания битых задач.

Для высоконагруженных систем рекомендуется периодически очищать буферную таблицу telegram_update. Достаточно хранить записи за последние 3–7 дней для разбора инцидентов. Ротацию можно выполнять через консольную команду Yii2, запускаемую по cron раз в сутки.

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

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

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