Telegram Webhook-контролер на Yii2: обхід CSRF, валідація Secret Token та Yii Queue

При розробці Telegram-бот ів на фреймворку Yii2 розробники часто стикаються з архітектурними проблемами. Telegram вимагає, щоб сервер відповідав на webhook-запити практично миттєво (протягом 1-2 секунд). Якщо ваш код починає виконувати важкі операції — відправку листів, запити до сторонніх CRM або складні розрахунки в БД — Telegram розриває з'єднання за таймаутом і починає надсилати повторні запити (retries). Це призводить до лавиноподібного зростання навантаження та дублювання повідомлень користувачам.

У цій статті ми побудуємо професійний, відмовостійкий Webhook-приймач на Yii2. Ми вирішимо проблему валідації токенів, обійдемо вбудований CSRF-захист, реалізуємо сувору ідемпотентність через збереження update_id у базу даних і перенесемо всю бізнес-логику у фонову чергу за допомогою розширення yii2-queue.

Крок 1: База даних та забезпечення ідемпотентності

Ідемпотентність гарантує, що один і той самий апдейт від Telegram не буде оброблений двічі. Мережеві збої — звичайна справа: Telegram може надіслати запит, не дочекатися відповіді через секундний лаг мережі, порахувати доставку невдалою і надіслати той самий update_id знову.

Для захисту від дублів створимо просту таблицю в базі даних, де первинним ключем виступатиме унікальний update_id. Використаємо стандартний механізм міграцій Yii2.

use yii\db\Migration;

class m231024_120000_create_tg_updates_table extends Migration
{
public function safeUp()
{
$this->createTable("{{%tg_processed_update}}", [
"update_id" => $this->bigInteger()->notNull()->unsigned(),
"processed_at" => $this->timestamp()->defaultExpression("CURRENT_TIMESTAMP"),
]);
$this->addPrimaryKey("pk-tg_processed_update-update_id", "{{%tg_processed_update}}", "update_id");
}

public function safeDown()
{
$this->dropTable("{{%tg_processed_update}}");
}
}

Спроба повторного вставлення вже обробленого update_id викличе помилку порушення унікальності ключа (Integrity Constraint Violation), яку ми легко перехопимо в контролері та повернемо Telegram успішний статус 200 OK, запобігаючи повторній обробці.

Крок 2: Створення Webhook-контролера з відключенням CSRF

За замовчуванням Yii2 захищає всі POST-запити за допомогою CSRF-токенів. Оскільки запити від Telegram надходять ззовні, Yii2 заблокує їх із помилкою 400 Bad Request. Нам необхідно вимкнути валідацію CSRF саме для екшену вебхука.

Також ми додамо перевірку секретного заголовка X-Telegram-Bot-Api-Secret-Token, який мы вказуємо при реєстрації вебхука через метод setWebhook. Це гарантує, що запити на наш URL надходять дійсно від серверів Telegram, а не від зловмисників, які дізналися адресу скрипту.

namespace app\controllers;

use Yii;
use yii\web\Controller;
use yii\web\BadRequestHttpException;
use yii\web\Response;
use app\queue\TelegramProcessorJob;

class TelegramController extends Controller
{
// Отключаем CSRF-валидацию для работы внешнего вебхука
public $enableCsrfValidation = false;

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

// Защита: проверяем <a href="/blog/telegram-webhook-pure-php-setup">секретный токен</a>, заданный при setWebhook
$expectedToken = Yii::$app->params["telegram_secret_token"] ?? null;
$receivedToken = $request->headers->get("X-Telegram-Bot-Api-Secret-Token");

if ($expectedToken && $receivedToken !== $expectedToken) {
Yii::warning("Попытка несанкционированного доступа к вебхуку", "telegram");
throw new BadRequestHttpException("Invalid secret token");
}

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

if (json_last_error() !== JSON_ERROR_NONE || !isset($update["update_id"])) {
return ["status" => "error", "message" => "Invalid JSON or missing update_id"];
}

$updateId = $update["update_id"];

// Проверяем идемпотентность через попытку вставки в базу данных
try {
Yii::$app->db->createCommand()
->insert("{{%tg_processed_update}}", ["update_id" => $updateId])
->execute();
} catch (\yii\db\Exception $e) {
// Если запись уже существует (код ошибки 23000 / 1062 дубликат)
if ($e->errorInfo[1] == 1062 || strpos($e->getMessage(), "23000") !== false) {
return ["status" => "ok", "message" => "Duplicate update ignored"];
}
throw $e;
}

// Отправляем задачу в очередь Yii Queue для асинхронной обработки
Yii::$app->queue->push(new TelegramProcessorJob([
"update" => $update,
]));

// Моментально отвечаем Telegram успехом
return ["status" => "ok"];
}
}

Крок 3: Асинхронна обробка подій через Yii Queue

Для фонової обробки ми використовуємо офіційний компонент yiisoft/yii2-queue. Він дозволяє додавати завдання в Redis, RabbitMQ, DB або Gearman і виконувати їх у консольних воркерах демона.

Створимо клас завдання (Job), який відповідатиме за безпосередню обробку вхідного повідомлення та відправку відповіді користувачеві.

namespace app\queue;

use Yii;
use yii\base\BaseObject;
use yii\queue\JobInterface;

class TelegramProcessorJob extends BaseObject implements JobInterface
{
/** @var array Входящий массив данных от Telegram */
public $update;

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

$chatId = $this->update["message"]["chat"]["id"];
$text = $this->update["message"]["text"] ?? "";

// Бизнес-логика бота
if (strpos($text, "/start") === 0) {
$this->sendMessage($chatId, "Привет! Ваша команда принята и обработана асинхронно через Yii Queue.");
}
}

private function sendMessage($chatId, $text)
{
$token = Yii::$app->params["telegram_bot_token"] ?? null;
if (!$token) {
Yii::error("Токен <a href="/blog/php-telegram-bot-getupdates-long-polling-local-dev">Telegram Bot API</a> не сконфигурирован", "telegram");
return;
}

$url = "https://api.telegram.org/bot{$token}/sendMessage";
$payload = json_encode([
"chat_id" => $chatId,
"text" => $text,
"parse_mode" => "HTML"
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);

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

if ($curlError) {
Yii::error("Ошибка cURL при отправке в Telegram: {$curlError}", "telegram");
return;
}

if ($httpCode !== 200) {
Yii::error("Telegram API вернул код {$httpCode}. Ответ: {$response}", "telegram");
return;
}

$result = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE || !($result["ok"] ?? false)) {
Yii::error("Некорректный ответ Telegram API: " . ($result["description"] ?? "Unknown"), "telegram");
}
}
}

Крок 4: Конфігурація додатка

Для коректної роботи системи додайте параметри токенів у конфігураційний файл config/params.php:

return [
"telegram_bot_token" => getenv("TELEGRAM_BOT_TOKEN"),
"telegram_secret_token" => getenv("TELEGRAM_SECRET_TOKEN"), // Любая случайная строка
];

Також переконайтеся, що компонент черги (queue) зареєстрований у конфігурації консольного та веб-додатка (config/web.php та config/console.php):

"components" => [
"queue" => [
"class" => \yii\queue\db\Queue::class,
"db" => "db", // Компонент подключения к БД
"tableName" => "{{%queue}}", // Таблица очереди
"channel" => "telegram",
"mutex" => \yii\mutex\MysqlMutex::class,
],
],

Типові помилки при інтеграції вебхуків у Yii2

  • Використання file_get_contents(\"php://input\"): У Yii2 для отримання сирого тіла запиту завжди слід використовувати метод Yii::$app->request->getRawBody(). Він кешує результат всередині фреймворку, що виключає проблеми з повторним читанням потоку введення.
  • Відсутність таймаутів на cURL: Завжди явно задавайте CURLOPT_TIMEOUT та CURLOPT_CONNECTTIMEOUT. Без них завислий запит до API Telegram заблокує воркер черги, знижуючи загальну пропускну здатність системи.
  • Відправка відповіді всередині HTTP-сесії контролера: Ніколи не виконуйте тривалу логіку всередині actionWebhook. Telegram розірве з'єднання, а користувач отримає дубльоване повідомлення, оскільки сервер поверне помилку таймауту, і Telegram повторить спробу доставки.

Реалізована архітектура легко масштабується: при зростанні навантаження достатньо запустити кілька воркерів черги за допомогою супервізора (Supervisor) командою php yii queue/listen.

Якщо вам потрібна професійна розробка складних інтеграцій та ботів під ключ, зверніться до фахівців BotCreator.

" }

Нові статті — у Telegram

Розбираємо, що автоматизувати в бізнесі та як це працює на практиці. Без спаму.