Архітектура Telegram-бота на Yii2: пишемо надійний TelegramClient з DI, логуванням та обробкою 429 помилок

Розробка відмовостійкого Telegram-бота вимагає більшого, ніж просто надсилання HTTP-запитів через file_get_contents. У реальному продакшені ви зіткнетеся з мережевими збоями, лімітами Telegram (rate limits) та необхідністю безпечно зберігати облікові дані. Використання патерну «Сервісний шар» (Service Layer) у зв'язці з Dependency Injection (DI) в Yii2 дозволяє ізолювати логіку спілкування з Telegram Bot API, зробити код тестованим та розширюваним.

1. Винесення токена в params-local.php

Ніколи не хардкодьте токен бота у тілі класів. Приватні ключі не повинні потрапляти до системи контролю версій (Git). У Yii2 для цього передбачено механізм розділення конфігурації на глобальну (комітиться в репозиторій) та локальну (ігнорується системою контролю версій).

Визначимо структуру конфігурації у файлі config/params.php, задавши дефолтні значення:

<?php
// config/params.php
return [
'telegram' => [
'botToken' => null, // Переопределяется локально
'maxRetries' => 3, // Максимальное количество попыток при ошибке 429
],
];

А реальний токен пропишемо в локальному конфігу, який додано до .gitignore:

<?php
// config/params-local.php
return [
'telegram' => [
'botToken' => '123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ',
],
];

2. Проєктування компонента TelegramClient

Створимо клас TelegramClient. Він відповідатиме за надсилання POST-запитів через cURL, валідацію відповідей, логування мережевих помилок та обробку лімітів (HTTP-код 429). При отриманні відповіді з кодом 429 клієнт автоматично призупинить виконання (sleep) на вказану Telegram кількість секунд і повторить запит.

<?php

namespace app\components;

use Yii;
use yii\base\Component;
use yii\base\InvalidConfigException;

class TelegramClient extends Component
{
private string $token;
private int $maxRetries;
private string $apiUrl = 'https://api.telegram.org/bot';

public function __construct(array $config = [])
{
if (empty($config['token'])) {
throw new InvalidConfigException('Параметр Telegram bot token должен быть настроен.');
}
$this->token = $config['token'];
$this->maxRetries = $config['maxRetries'] ?? 3;
parent::__construct([]);
}

/**
* Отправка запроса к Telegram Bot API
* @param string $method Метод API (например, sendMessage)
* @param array $params Параметры запроса
* @param int $attempt Текущая попытка (для ретраев)
* @return array
* @throws \RuntimeException
*/
public function sendRequest(string $method, array $params = [], int $attempt = 1): array
{
$url = $this->apiUrl . $this->token . '/' . $method;
$ch = curl_init();

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

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

// Обработка транспортных ошибок cURL
if ($curlErrno !== 0) {
Yii::error("cURL error calling {$method}: {$curlError} ({$curlErrno})", 'telegram');
throw new \RuntimeException("Сбой сети при обращении к Telegram API: {$curlError}");
}

$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Yii::error("Некорректный JSON от Telegram API в методе {$method}. Ответ: {$response}", 'telegram');
throw new \RuntimeException("Ошибка декодирования JSON от Telegram API");
}

// Обработка логических ошибок Telegram Bot API
if (isset($data['ok']) && $data['ok'] === false) {
$errorCode = $data['error_code'] ?? 0;
$description = $data['description'] ?? 'No description';

// Обработка лимитов (429 Too Many Requests)
if ($errorCode === 429 && $attempt <= $this->maxRetries) {
$retryAfter = $data['parameters']['retry_after'] ?? 1;
Yii::warning("Лимит запросов превышен (429). Ожидание {$retryAfter} сек. Попытка {$attempt}/{$this->maxRetries}", 'telegram');
sleep($retryAfter);
return $this->sendRequest($method, $params, $attempt + 1);
}

Yii::error("Telegram API вернул ошибку {$errorCode}: {$description} (Метод: {$method})", 'telegram');
}

return $data;
}
}

3. Реєстрація TelegramClient у DI-контейнері Yii2

Щоб не створювати екземпляр класу вручную через оператор new і не залежати від глобального стану, зареєструємо наш клієнт у контейнері залежностей (Dependency Injection) Yii2. Це робиться в конфігураційному файлі додатка (наприклад, config/web.php або config/main.php для консолі).

<?php
// config/web.php
$params = require __DIR__ . '/params.php';
if (file_exists(__DIR__ . '/params-local.php')) {
$params = yii\helpers\ArrayHelper::merge($params, require __DIR__ . '/params-local.php');
}

$config = [
'id' => 'basic',
'basePath' => dirname(__DIR__),
'bootstrap' => ['log'],
'container' => [
'definitions' => [
app\components\TelegramClient::class => function () use ($params) {
return new app\components\TelegramClient([
'token' => $params['telegram']['botToken'] ?? null,
'maxRetries' => $params['telegram']['maxRetries'] ?? 3,
]);
},
],
],
// ... остальные настройки компонента log и db
];

return $config;

4. Використання клієнта в контролерах і сервісах

Завдяки DI-контейнеру Yii2, ми можемо впровадити TelegramClient безпосередньо в конструктор контролера або консольної команди. Yii автоматично вирішить залежності й передасть налаштований об'єкт.

<?php

namespace app\controllers;

use yii\web\Controller;
use app\components\TelegramClient;
use Yii;

class BotController extends Controller
{
private TelegramClient $telegram;

// Внедрение зависимости через конструктор
public function __construct($id, $module, TelegramClient $telegram, $config = [])
{
$this->telegram = $telegram;
parent::__construct($id, $module, $config);
}

/**
* Пример отправки сообщения пользователю
*/
public function actionNotifyUser()
{
$chatId = Yii::$app->request->post('chat_id');
$message = Yii::$app->request->post('message');

if (!$chatId || !$message) {
return $this->asJson(['success' => false, 'error' => 'Missing parameters']);
}

try {
$result = $this->telegram->sendRequest('sendMessage', [
'chat_id' => $chatId,
'text' => $message,
'parse_mode' => 'HTML',
]);

if (isset($result['ok']) && $result['ok'] === true) {
return $this->asJson(['success' => true, 'message_id' => $result['result']['message_id']]);
}

return $this->asJson([
'success' => false,
'error' => $result['description'] ?? 'Неизвестная ошибка Telegram API'
]);

} catch (\Exception $e) {
return $this->asJson(['success' => false, 'error' => $e->getMessage()]);
}
}
}

5. Налаштування логування помилок Telegram API в Yii2

Усі помилки, перехоплені в нашому клієнті за допомогою Yii::error() та категорійним тегом 'telegram', мають записуватися в окремий файл логів. Це спрощує налагодження в продакшені. Додайте новий таргет у конфігурацію компонента log у файлі config/web.php:

'components' => [
'log' => [
'targets' => [
[
'class' => 'yii\log\FileTarget',
'levels' => ['error', 'warning'],
'categories' => ['telegram'],
'logFile' => '@runtime/logs/telegram.log',
'logVars' => [], // Отключаем дамп глобальных переменных для приватности
],
],
],
]

Висновок

Використання виділеного сервісного шару TelegramClient з інтеграцією через DI-контейнер вирішує відразу кілька архітектурних проблем. Ви позбавляєтеся дублювання коду cURL-запитів, забезпечуєте централізоване логирование ошибок, захищаєте токени від витоку в публічні репозиторії та робите додаток стійким до тимчасових блокувань Telegram (HTTP 429) завдяки автоматичним повторним спробам.

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

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

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