Webhook-контроллер для Telegram-бота на Yii2: secret_token, идемпотентность и очередь

Webhook — основной способ получать апдейты Telegram-бота в продакшене: быстрее long polling и нормально живёт за балансировщиком. В Yii2 приём делается в одном action, но именно здесь чаще ломаются CSRF, проверка секрета и идемпотентность по update_id. Разберём рабочий контроллер с очередью.

1. setWebhook и параметры

Токен и секрет — только в params / ENV. Установка webhook:

<?php
// console или одноразовый скрипт деплоя
$token = Yii::$app->params['telegram.botToken'];
$secret = Yii::$app->params['telegram.webhookSecret']; // 16–64 символа
$url = 'https://example.com/telegram/webhook';

$ch = curl_init("https://api.telegram.org/bot{$token}/setWebhook");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_POSTFIELDS => [
'url' => $url,
'secret_token' => $secret,
'allowed_updates' => json_encode(['message', 'callback_query']),
'drop_pending_updates' => false,
],
]);
$raw = curl_exec($ch);
curl_close($ch);
$result = json_decode((string) $raw, true);
if (empty($result['ok'])) {
throw new RuntimeException($result['description'] ?? 'setWebhook failed');
}

2. CSRF только для action webhook

У Telegram нет CSRF-токена Yii. Глобально выключать защиту не нужно — отключите её точечно.

<?php
namespace app\controllers;

use Yii;
use yii\web\Controller;
use yii\web\Response;
use yii\filters\VerbFilter;

class TelegramController extends Controller
{
public $enableCsrfValidation = true;

public function beforeAction($action)
{
if ($action->id === 'webhook') {
$this->enableCsrfValidation = false;
}
return parent::beforeAction($action);
}

public function behaviors()
{
return [
'verbs' => [
'class' => VerbFilter::class,
'actions' => [
'webhook' => ['POST'],
],
],
];
}
}

3. Проверка secret_token через hash_equals

Telegram шлёт заголовок X-Telegram-Bot-Api-Secret-Token. Сравнивайте константно по времени.

private function assertSecret(): void
{
$expected = (string) Yii::$app->params['telegram.webhookSecret'];
$got = (string) Yii::$app->request->headers->get(
'X-Telegram-Bot-Api-Secret-Token',
''
);
if ($expected === '' || !hash_equals($expected, $got)) {
Yii::$app->response->statusCode = 403;
throw new \yii\web\ForbiddenHttpException('bad secret');
}
}

4. Таблица идемпотентности

CREATE TABLE processed_updates (
update_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

5. Action webhook + Yii Queue

Telegram ждёт ответ ~60 секунд. В action только проверка, фиксация update_id и пуш в очередь. Тяжёлую логику — в воркер.

<?php
use app\jobs\ProcessTelegramUpdateJob;
use yii\db\IntegrityException;

public function actionWebhook()
{
Yii::$app->response->format = Response::FORMAT_RAW;
$this->assertSecret();

$raw = Yii::$app->request->getRawBody();
$update = json_decode($raw, true);
if (!is_array($update) || !isset($update['update_id'])) {
Yii::$app->response->statusCode = 400;
return 'bad payload';
}

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

$tx = $db->beginTransaction();
try {
$db->createCommand()->insert('processed_updates', [
'update_id' => $updateId,
])->execute();

Yii::$app->queue->push(new ProcessTelegramUpdateJob([
'update' => $update,
]));

$tx->commit();
} catch (IntegrityException $e) {
$tx->rollBack();
// дубль update_id — уже обработан, отвечаем 200
} catch (\Throwable $e) {
$tx->rollBack();
Yii::error($e->getMessage(), __METHOD__);
Yii::$app->response->statusCode = 500;
return 'error';
}

Yii::$app->response->statusCode = 200;
return 'ok';
}

6. cURL-клиент к Bot API

<?php
namespace app\components;

use Yii;
use yii\base\Component;
use RuntimeException;

class TelegramApi extends Component
{
public string $token;

public function call(string $method, array $params = []): array
{
$ch = curl_init("https://api.telegram.org/bot{$this->token}/{$method}");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
CURLOPT_POSTFIELDS => $params,
]);
$body = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($body === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException("cURL: {$err}");
}
curl_close($ch);

$data = json_decode($body, true);
if (!is_array($data) || empty($data['ok'])) {
$desc = is_array($data) ? ($data['description'] ?? 'unknown') : 'bad JSON';
throw new RuntimeException("TG {$http}: {$desc}");
}
return $data['result'];
}

public function sendMessage($chatId, string $text, array $extra = []): array
{
return $this->call('sendMessage', array_merge([
'chat_id' => $chatId,
'text' => $text,
], $extra));
}
}

Регистрация в конфиге: 'telegramApi' => ['class' => TelegramApi::class, 'token' => $params['telegram.botToken']].

Чек-лист

  • CSRF выключен только для actionWebhook.
  • Секрет сравнивается через hash_equals.
  • Дубли update_id отсекаются PK / IntegrityException.
  • Ответ 200 уходит быстро; тяжёлая работа — в Yii Queue.
  • Исходящие вызовы — через cURL с проверкой ok.

Для локальной отладки без HTTPS см. getUpdates long polling. Обслуживание webhook из консоли — в команде обслуживания.

Готовый каркас webhook + очередь — на botservice.biz.

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

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