Telegram Webhook-контролер на Yii2: CSRF, Secret Token, дедуплікація та черги

Розробка відмовостійкого Telegram-бот а на PHP в межах фреймворку Yii2 вимагає суворого дотримання двох головних правил Bot API: миттєва відповідь HTTP 200 OK серверам Telegram та захист кінцевої точки (endpoint) від несанкціонованого доступу. При високому навантаженні або затримках на сторонніх API (наприклад, CRM або бази даних) обробка вхідного запиту безпосередньо під час HTTP-сесії призводить до таймаутів (6 секунд), повторних спроб з боку Telegram та падіння бота.

У цьому гайді ми покроково зберемо високопродуктивний контролер вебхука на Yii2, вимкнемо CSRF-валідацію для бота, авторизуємо запит через заголовок X-Telegram-Bot-Api-Secret-Token, захистимося від дублювання оновлень за допомогою update_id та винесемо всю бізнес-логіку у фонові завдання Yii Queue.

1. Архітектура Webhook-контролера та вимкнення CSRF

За замовчуванням у Yii2 увімкнено захист від Cross-Site Request Forgery (CSRF). Оскільки Telegram надсилає вхідний POST-запит із JSON-корисним навантаженням без CSRF-токена фреймворку, перший крок — вимкнути цю перевірку для конкретного контролера або дії. Крім того, вебхук повинен приймати тільки метод POST і працювати через ізольований actionWebhook.

Для виключення затримок контролер не повинен виконувати складну бізнес-логіку. Його завдання — розпарсити тіло запиту, перевірити справжність токена, переконатися в унікальності update_id, поставити завдання в чергу та повернути HTTP 200.

2. Валідація Secret Token та збереження update_id

Під час встановлення вебхука через метод setWebhook розробник може передати параметр secret_token (рядок від 1 до 256 символів). Telegram надсилатиме цей токен у кожному POST-запиті в заголовку X-Telegram-Bot-Api-Secret-Token. Це захистить ваш бекенд від підробки запитів зловмисниками.

Друга проблема — дублювання оновлень (retries). Якщо ваш сервер не відповів за відведений час або стався збій мережі, Telegram повторить запит з тим самим update_id. Щоб уникнути повторної обробки замовлень або подвійного списання бонусів, ми повинні фіксувати update_id у базі даних (або в Redis) з використанням UNIQUE індексу.

<?php

namespace app\controllers;

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

class WebhookController extends Controller
{
// Отключаем CSRF-проверку для работы с внешним API Telegram
public $enableCsrfValidation = false;

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

if (!$request->isPost) {
Yii::$app->response->statusCode = 405;
return ['ok' => false, 'error' => 'Method Not Allowed'];
}

// 1. Проверка X-Telegram-Bot-Api-Secret-Token
$secretToken = getenv('TELEGRAM_SECRET_TOKEN') ?: Yii::$app->params['telegramSecretToken'];
$incomingToken = $request->headers->get('X-Telegram-Bot-Api-Secret-Token');

if (empty($secretToken) || $incomingToken !== $secretToken) {
Yii::$app->response->statusCode = 403;
return ['ok' => false, 'error' => 'Access denied: invalid secret token'];
}

// 2. Чтение и валидация JSON-тела
$rawContent = $request->getRawBody();
$payload = json_decode($rawContent, true);

if (json_last_error() !== JSON_ERROR_NONE || !isset($payload['update_id'])) {
Yii::$app->response->statusCode = 400;
return ['ok' => false, 'error' => 'Invalid JSON payload'];
}

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

// 3. Защита от дублей (дедупликация по update_id)
if (!$this->registerUpdateId($updateId)) {
// Update уже получали ранее, отдаем 200 OK без повторной постановки в очередь
return ['ok' => true, 'status' => 'already_processed'];
}

// 4. Отправка апдейта на обработку в очередь Yii Queue
Yii::$app->queue->push(new ProcessTelegramUpdateJob([
'updatePayload' => $payload,
]));

// Возвращаем быстрый 200 OK
return ['ok' => true, 'status' => 'queued'];
}

/**
* Регистрирует update_id в атомарном хранилище.
* Возвращает false, если update_id уже существовал в БД.
*/
private function registerUpdateId(int $updateId): bool
{
$db = Yii::$app->db;
try {
$affected = $db->createCommand()
->insert('{{%telegram_processed_updates}}', [
'update_id' => $updateId,
'created_at' => date('Y-m-d H:i:s'),
])
->execute();

return $affected > 0;
} catch (\Exception $e) {
// В случае нарушения UNIQUE ограничения ловим исключение
return false;
}
}
}

3. Міграція для таблиці дедуплікації update_id

Для коректної роботи методу registerUpdateId знадобиться структура таблиці в СУБД з первинним або унікальним ключем за полем update_id. Нижче представлена стандартна міграція Yii2.

<?php

use yii\db\Migration;

class m240101_000001_create_telegram_processed_updates_table extends Migration
{
public function safeUp()
{
$this->createTable('{{%telegram_processed_updates}}', [
'update_id' => $this->bigInteger()->notNull(),
'created_at' => $this->dateTime()->notNull(),
]);

$this->addPrimaryKey('pk_telegram_processed_updates', '{{%telegram_processed_updates}}', 'update_id');
}

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

4. Фонова обробка через Yii Queue та безпечне надсилання відповіді

Отримані дані потрапляють у компонент Yii Queue (наприклад, на базі Redis, RabbitMQ або DB). Черга розбирається консольним воркером (php yii queue/listen). Усередині завдання відбувається вся логіка: обробка текстових команд, запис лідів у CRM, генерація відповідей.

Для взаємодії з API Telegram на PHP використовуємо чистий cURL без сторонніх обгорток із глибокою перевіркою помилок мережі, статус-кодів HTTP та полів відповіді ok: true.

<?php

namespace app\jobs;

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

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

public function execute($queue)
{
$botToken = getenv('TELEGRAM_BOT_TOKEN') ?: Yii::$app->params['telegramBotToken'];

if (empty($botToken)) {
Yii::error('Telegram Token is missing in configuration', 'telegram');
return;
}

// Пример обработки входящего текстового сообщения
if (isset($this->updatePayload['message'])) {
$message = $this->updatePayload['message'];
$chatId = $message['chat']['id'] ?? null;
$text = trim($message['text'] ?? '');

if ($chatId && $text !== '') {
$this->handleMessage($botToken, $chatId, $text);
}
}
}

private function handleMessage(string $botToken, int $chatId, string $text): void
{
if (strpos($text, '/start') === 0) {
$replyText = "Здравствуйте! Ваш запрос передан в фоновый воркер и успешно обработан.";
} else {
$replyText = "Принято сообщение: <b>" . htmlspecialchars($text, ENT_QUOTES, 'UTF-8') . "</b>";
}

$this->sendTelegramApi($botToken, 'sendMessage', [
'chat_id' => $chatId,
'text' => $replyText,
'parse_mode' => 'HTML',
]);
}

/**
* Универсальный метод отправки запросов к Bot API через cURL
*/
private function sendTelegramApi(string $botToken, string $method, array $params): bool
{
$url = "https://api.telegram.org/bot{$botToken}/{$method}";

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

$rawResponse = curl_exec($ch);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($curlErrno !== 0) {
Yii::error("cURL error ({$curlErrno}): {$curlError}", 'telegram');
return false;
}

if ($httpCode !== 200) {
Yii::error("Telegram API HTTP status {$httpCode}. Response: {$rawResponse}", 'telegram');
return false;
}

$response = json_decode($rawResponse, true);
if (json_last_error() !== JSON_ERROR_NONE || empty($response['ok'])) {
Yii::error("Telegram API returns error logic. Payload: {$rawResponse}", 'telegram');
return false;
}

return true;
}
}

5. Рекомендації щодо експлуатації та безпеки

  • Таймауты cURL: Завжди обмежуйте CURLOPT_TIMEOUT та CURLOPT_CONNECTTIMEOUT, щоб воркер черги не зависав при проблемах із мережею.
  • Секретні ключі: Ніколи не зберігайте токени бота та secret_token прямо в коді. Використовуйте переменные окружения (.env) або компонент конфігурації Yii2 (Yii::$app->params).
  • Очищення старих update_id: Налаштуйте періодичну консольну команду (Yii2 Console Runner / Cron) для видалення записів із telegram_processed_updates старіше 7 днів, щоб таблиця не розросталася до мільйонів рядків.
  • Логирование ошибок: Налаштуйте вбудовані таргети логів Yii2 (yii\log\FileTarget) для категорії telegram, щоб оперативно дізнаватися про помилки типу 403 Forbidden або 429 Too Many Requests.

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

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

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