При розробці високонавантажених 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 допоможе реалізувати відмовостійкі рішення будь-якої складності.