При разработке высоконагруженных Telegram-ботов на Yii2 стандартный подход с обработкой обновлений «на лету» быстро упирается в ограничения платформы. Telegram ожидает от вашего сервера быстрый ответ HTTP 200 OK (желательно в пределах 1–2 секунд). Если ваш код начинает выполнять «тяжелые» операции — запросы к сторонним API, генерацию изображений или сложные транзакции в БД — таймаут превышается. Telegram расценивает это как сбой доставки и начинает повторно отправлять те же самые события (updates), что лавинообразно увеличивает нагрузку и приводит к дублированию действий.
Архитектурный паттерн: Быстрый прием и фоновая обработка
Единственный надежный способ спроектировать вебхук — разделить прием сообщений и их бизнес-обработку. Схема выглядит так:
- Контроллер принимает POST-запрос от Telegram.
- Проверяется подпись запроса (Secret Token) для защиты от спама.
- Выполняется проверка на дубликаты (идемпотентность) по уникальному
update_id. - Сырой JSON сохраняется в буферную таблицу БД со статусом
pending. - В очередь (Yii Queue) отправляется легковесная задача на обработку.
- Контроллер мгновенно возвращает ответ HTTP 200 OK.
- Фоновый воркер асинхронно обрабатывает задачу из очереди.
Шаг 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 поможет реализовать отказоустойчивые решения любой сложности.