Консольная команда Yii2 для обслуживания бота: setWebhook, getMe и ротация логов

Управление Webhook вручную через браузер или сторонние HTTP-клиенты — частый источник проблем в продакшене. При ручной настройке легко забыть передать secret_token, пропустить смену URL при смене окружения или потерять контроль над зависшими накопившимися апдейтами. Единственный верный подход для коммерческой разработки — выносить сервисные задачи бота в CLI-команды фреймворка.

В этой статье мы разберем создание консольного контроллера в Yii2, который отвечает за проверку доступности API (health-check), безопасную регистрацию и удаление Webhook, а также автоматическую очистку таблиц идемпотентности и логов апдейтов.

Зачем выносить обслуживание Telegram API в консоль Yii2

Использование консольных команд Yii2 (классы, наследуемые от yii\console\Controller) дает сразу несколько инженерных преимуществ:

  • Интеграция в CI/CD pipeline: При каждом деплое пайплайн может автоматически вызывать health-check и обновлять Webhook URL с актуальным secret_token.
  • Единая конфигурация: Токены и параметры берутся из окружения (getenv()) или Yii::$app->params, исключая утечку токенов в историю команд cURL.
  • Безопасное удаление накопившихся сообщений: Флаг drop_pending_updates при сбросе вебхука позволяет очистить очередь от накопившихся сообщений после сбоя серверов.
  • Автоматическая профилактика БД: Таблицы, хранящие update_id для обеспечения идемпотентности, со временем разрастаются до миллионов строк, требуя регулярной очистки по расписанию через Cron.

Реализация CLI-контроллера: getMe, setWebhook и deleteWebhook

Создадим консольный контроллер commands/TelegramController.php. Для взаимодействия с Telegram Bot API используем чистый cURL с обязательной валидацией HTTP-кода ответа, проверкой json_last_error и поля ok в теле ответа.

<?php

namespace app\commands;

use Yii;
use yii\console\Controller;
use yii\console\ExitCode;

class TelegramController extends Controller
{
/**
* Отправка POST-запроса к Telegram Bot API через cURL
*/
private function sendApiRequest(string $method, array $params = []): array
{
$token = getenv('TELEGRAM_BOT_TOKEN') ?: (Yii::$app->params['telegramBotToken'] ?? null);
if (!$token) {
$this->stderr("Ошибка: Не задан TELEGRAM_BOT_TOKEN в getenv() или params.php\n");
return ['ok' => false, 'error' => 'Missing bot token'];
}

$url = "https://api.telegram.org/bot{$token}/{$method}";
$ch = curl_init($url);

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

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

if ($response === false) {
$this->stderr("cURL Error: {$curlError}\n");
return ['ok' => false, 'error' => $curlError];
}

$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->stderr("JSON Error: " . json_last_error_msg() . "\n");
return ['ok' => false, 'error' => 'Invalid JSON response'];
}

if ($httpCode !== 200 || empty($data['ok'])) {
$description = $data['description'] ?? 'Unknown API error';
$this->stderr("Telegram API Error [HTTP {$httpCode}]: {$description}\n");
}

return $data;
}

/**
* Health-check доступности бота (getMe)
*/
public function actionHealth(): int
{
$res = $this->sendApiRequest('getMe');
if (!empty($res['ok'])) {
$botUsername = $res['result']['username'] ?? 'unknown_bot';
$botName = $res['result']['first_name'] ?? 'Bot';
$this->stdout("Health-check пройден успешно. Имя: {$botName} (@{$botUsername})\n");
return ExitCode::OK;
}

$this->stderr("Health-check провален! Проверьте валидность токена.\n");
return ExitCode::UNSPECIFIED_ERROR;
}

/**
* Установка Webhook с привязкой Secret Token
*/
public function actionSetWebhook(string $url): int
{
$secretToken = getenv('TELEGRAM_SECRET_TOKEN') ?: (Yii::$app->params['telegramSecretToken'] ?? '');

$params = [
'url' => $url,
'allowed_updates' => ['message', 'callback_query', 'my_chat_member'],
];

if (!empty($secretToken)) {
$params['secret_token'] = $secretToken;
}

$res = $this->sendApiRequest('setWebhook', $params);
if (!empty($res['ok'])) {
$this->stdout("Webhook успешно зарегистрирован на URL: {$url}\n");
return ExitCode::OK;
}

return ExitCode::UNSPECIFIED_ERROR;
}

/**
* Удаление Webhook с опциональным сбросом очереди накопившихся апдейтов
*/
public function actionDeleteWebhook(bool $dropPending = false): int
{
$params = ['drop_pending_updates' => $dropPending];
$res = $this->sendApiRequest('deleteWebhook', $params);

if (!empty($res['ok'])) {
$status = $dropPending ? "с очисткой входящей очереди" : "без очистки очереди";
$this->stdout("Webhook успешно удален ({$status}).\n");
return ExitCode::OK;
}

return ExitCode::UNSPECIFIED_ERROR;
}
}

Ротация логов апдейтов и очистка таблиц идемпотентности

При получении апдейта от Telegram на контроллер Webhook его update_id сохраняется в базу данных (например, в таблицу telegram_update_log), чтобы исключить дублирующую обработку. Со временем эта таблица разрастается до сотен тысяч записей, занимая дисковое пространство и снижая скорость поиска индексов.

Добавим в наш консольный контроллер метод для фоновой очистки устаревших записей, сохраняя только данные за последние N дней.

    /**
* Очистка старых логов входящих апдейтов (для поддержания идемпотентности)
* Пример вызова: php yii telegram/clean-logs 14
*/
public function actionCleanLogs(int $days = 14): int
{
if ($days < 1) {
$this->stderr("Параметр days должен быть больше или равен 1.\n");
return ExitCode::DATAERR;
}

$thresholdDate = date('Y-m-d H:i:s', strtotime("-{$days} days"));

try {
$deletedCount = Yii::$app->db->createCommand()
->delete('{{%telegram_update_log}}', ['<', 'created_at', $thresholdDate])
->execute();

$this->stdout("Успешно удалено {$deletedCount} записей update_id старше {$days} дней ({$thresholdDate}).\n");
return ExitCode::OK;
} catch (\Exception $e) {
$this->stderr("Ошибка при выполнении очистки БД: " . $e->getMessage() . "\n");
return ExitCode::UNSPECIFIED_ERROR;
}
}

Автоматизация: использование в CI/CD и Crontab

Разработанная консольная команда может быть легко внедрена в деплой-скрипт (например, в bash-скрипт при разворачивании новой версии приложения) и в системный планировщик задач Linux (crontab).

# 1. Скрипт деплоя приложения (deploy.sh)
#!/usr/bin/env bash
set -e

APP_ENV="prod"
WEBHOOK_URL="https://bot.example.com/telegram/webhook"

echo "Проверка подключения к Telegram API..."
php yii telegram/health

echo "Регистрация целевого Webhook..."
php yii telegram/set-webhook "$WEBHOOK_URL"

# 2. Задача в /etc/cron.d/telegram-bot
# Запуск очистки устаревших логов update_id каждое воскресенье в 03:00 ночи (хранить 14 дней)
0 3 * * 0 www-data /usr/bin/php /var/www/app/yii telegram/clean-logs 14 >> /var/www/app/runtime/logs/cron-telegram.log 2>&1

Обработка ошибок и типовые подводные камни

При использовании CLI-команд в продуктовой среде учитывайте следующие нюансы:

  • Коды возврата (ExitCode): Команды должны возвращать ExitCode::OK (0) при успехе и коды отличные от нуля (например, ExitCode::UNSPECIFIED_ERROR или 1) при сбоях. Это необходимо, чтобы CI/CD скрипты вовремя прерывали сборку при возникновении ошибок API.
  • Секретный токен (Secret Token): Передаваемый параметр secret_token при вызове setWebhook должен содержать от 1 до 256 символов (допустимы символы A-Z, a-z, 0-9, _ и -). Валидируйте заголовок X-Telegram-Bot-Api-Secret-Token при приеме запросов на стороне веб-контроллера.
  • Drop pending updates: Будьте осторожны с флагом dropPending = true при удалении или переустановке вебхука. Используйте его только тогда, когда произошел сбой и накопившаяся очередь устаревших команд может положить ваш бизнес-логику или привести к дублированию действий.

Если вам необходима квалифицированная помощь в проектировании высоконагруженной архитектуры бота или интеграции вебхуков с CRM-системами, обратитесь к специалистам компании BotCreator.

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

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