Webhook-контроллер Telegram на Yii2: отмена CSRF, Secret Token, дедупликация и Yii Queue

При высокой нагрузке или нестабильной работе внешних сервисов синхронная обработка входящих запросов от Telegram Bot API неизбежно приводит к сбоям. Если ваш сервер не ответит кодом HTTP 200 OK в течение нескольких секунд, Telegram начнет повторно отправлять те же самые события (Update). Это создает лавинообразный рост нагрузки и повторные дублирующие действия в вашей системе.

В этом руководстве мы разберем создание архитектурно правильного контроллера на Yii2, который мгновенно принимает Webhook, проверяет подлинность заголовков, блокирует повторные запросы по update_id и передает обработку задачи в фоновое окружение через компонент yii2-queue.

1. Почему важно отвечать 200 OK мгновенно

Telegram Bot API ожидает от вебхука быстрый ответ с кодом 200 OK. Если время ожидания превышает допустимый таймаут, сервер Telegram считает доставку неудачной. В результате повторных попыток ваш бот может несколько раз записать одного и того же клиента в базу или отправить ему дублирующие уведомления.

Чтобы этого избежать, контроллер должен выполнять минимум работы: проверить секретный токен, зарегистрировать факт получения update_id, положить сырые данные массива в очередь и сразу вернуть статус успешного приема.

2. Отключение CSRF и валидация X-Telegram-Bot-Api-Secret-Token

По умолчанию yii\web\Controller проверяет CSRF-токен для всех POST-запросов. Telegram не знает ничего про Yii2 CSRF cookie, поэтому валидацию нужно выключить для action-вебхука. Для защиты от поддельных запросов используется механизм secret_token, передаваемый при настройке setWebhook.

Сервер Telegram отправляет этот секрет в заголовке X-Telegram-Bot-Api-Secret-Token. Сравнение строк необходимо проводить через бета-безопасную функцию hash_equals() для исключения атак по времени (timing attacks).

3. Контроллер WebhookController для Yii2

Ниже представлен полноценный код контроллера. Мы отключаем CSRF, валидируем заголовок в методе beforeAction, проверяем уникальность update_id в базе данных и отправляем задачу в очередь.

<?php

namespace app\controllers;

use Yii;
use yii\web\Controller;
use yii\web\BadRequestHttpException;
use yii\web\Response;
use app\jobs\ProcessTelegramUpdateJob;

class WebhookController extends Controller
{
public $enableCsrfValidation = false;

public function beforeAction($action)
{
if ($action->id === 'index') {
$secret = getenv('TELEGRAM_SECRET_TOKEN') ?: (Yii::$app->params['telegram_secret_token'] ?? null);
$headerToken = Yii::$app->request->headers->get('X-Telegram-Bot-Api-Secret-Token');

if (!$secret || !$headerToken || !hash_equals($secret, (string)$headerToken)) {
Yii::warning('Невалидный X-Telegram-Bot-Api-Secret-Token', 'telegram');
throw new BadRequestHttpException('Access denied');
}
}

return parent::beforeAction($action);
}

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

$rawBody = Yii::$app->request->getRawBody();
$update = json_decode($rawBody, true);

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

$updateId = (int)$update['update_id'];
$db = Yii::$app->db;

// Проверка идемпотентности через атомарную вставку или выборку
$exists = $db->createCommand('SELECT 1 FROM {{%telegram_updates}} WHERE update_id = :id', [
':id' => $updateId,
])->queryScalar();

if ($exists) {
return ['status' => 'ok', 'message' => 'Duplicate update'];
}

$db->createCommand()->insert('{{%telegram_updates}}', [
'update_id' => $updateId,
'created_at' => date('Y-m-d H:i:s'),
])->execute();

// Отправка в Yii Queue
Yii::$app->queue->push(new ProcessTelegramUpdateJob([
'update' => $update,
]));

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

4. Фоновая обработка апдейта через Yii Queue Job

Задача очереди извлекает тело апдейта, анализирует входящий текст или команды, выполняет бизнес-логику (например, генерацию лида) и формирует ответ обратно пользователю. Ниже пример класса ProcessTelegramUpdateJob.

<?php

namespace app\jobs;

use Yii;
use yii\base\BaseObject;
use yii\queue\JobInterface;
use app\components\TelegramSender;

class ProcessTelegramUpdateJob extends BaseObject implements JobInterface
{
public array $update = [];

public function execute($queue)
{
if (!isset($this->update['message'])) {
return;
}

$message = $this->update['message'];
$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,
'chat_id' => $chatId,
'status' => 'new',
'created_at' => date('Y-m-d H:i:s'),
])->execute();

$sender = new TelegramSender();
$sender->sendMessage([
'chat_id' => $chatId,
'text' => "Заявка принята! Ваш номер лида: <strong>{$leadId}</strong>",
'parse_mode' => 'HTML',
]);
}
}
}

5. Сервис отправки сообщений через cURL в Telegram API

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

<?php

namespace app\components;

use Yii;
use RuntimeException;

class TelegramSender
{
private string $token;

public function __construct()
{
$this->token = (string)(getenv('TELEGRAM_BOT_TOKEN') ?: (Yii::$app->params['telegram_bot_token'] ?? ''));
if (empty($this->token)) {
throw new RuntimeException('Не задан TELEGRAM_BOT_TOKEN');
}
}

public function sendMessage(array $params): array
{
return $this->request('sendMessage', $params);
}

public function request(string $method, array $params = []): array
{
$url = 'https://api.telegram.org/bot' . $this->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);
$curlError = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($response === false) {
throw new RuntimeException('Ошибка cURL при запросе к Telegram: ' . $curlError);
}

$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException('Невалидный JSON ответа от Telegram API');
}

if ($httpCode !== 200 || !isset($data['ok']) || $data['ok'] !== true) {
$description = $data['description'] ?? 'Неизвестная ошибка API';
Yii::error("Telegram API Error [{$httpCode}]: {$description}", 'telegram');
throw new RuntimeException("Telegram API Error: {$description}");
}

return $data['result'];
}
}

6. Типичные ошибки при реализации Webhook в Yii2

  • Забытый return true в beforeAction: Если не вернуть результат parent::beforeAction($action), выполнение контроллера прервется и action не вызовется.
  • Хардкод токенов: Токен бота и секретный ключ должны передаваться через переменные окружения (getenv()) или изолированный конфигурационный файл params-local.php.
  • Синхронные внешние запросы: Обращение к сторонним CRM или AI-сервисам прямо в теле контроллера неизбежно вызывает таймауты и заставляет Telegram отправлять дубли сообщений.

Команда BotCreator разработает для вашего бизнеса отказоустойчивого Telegram-бота любой сложности с полной интеграцией во внутренние системы компании.

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

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