Интеграция Telegram в Laravel без магии: свой HTTP-клиент, очереди, Webhook и тесты

Разработчики часто выбирают сторонние пакеты вроде nutgram/nutgram или irazasyed/telegram-bot-sdk для быстрой сборки ботов. Однако в высоконагруженных или долгоживущих проектах на Laravel готовые библиотеки добавляют лишний уровень абстракции, ухудшают гибкость при работе с Laravel Queue и осложняют точечное юнит-тестирование. Telegram Bot API — это лаконичный REST-подобный протокол с JSON-клиентами, поэтому написать собственный прозрачный слой интеграции оказывается проще и надежнее.

В этой статье мы построим интеграцию Telegram Bot API в архитектуру Laravel: напишем тонкий сервисный cURL-клиент, настроим эндпоинт Webhook с валидацией заголовочных ключей и гарантией идемпотентности, вынесем тяжелую бизнес-логику в фоновые задачи (Jobs) и напишем автоматические Feature-тесты.

Выбор архитектуры: тонкий cURL-клиент против heavy-пакетов

Использование сторонних SDK сопряжено с несколькими рисками в экосистеме Laravel:

  • Лишний оверхед и утечки памяти: многие пакеты тащат собственные кастомные контейнеры или реестры роутов, которые могут течь при выполнении воркеров php artisan queue:work.
  • Сложность кастомизации: если вам требуется специфическая обработка ошибок 429 (Too Many Requests), повторные запросы (retry logic) или логирование в отдельные каналы Monolog, подстраивать сторонний SDK становится сложнее, чем обернуть cURL.
  • Проблемы с тестированием: мокать глубокие структуры классов пакетов в тестах значительно дольше, чем подменять один сервисный класс или использовать встроенный HTTP-феик.

Минимальная надежная архитектура состоит из трех элементов: контроллер вебхука (принимает запрос, проверяет подлинность, сохраняет update_id и быстро отвечает 200 OK), фоновая Job-задача (обрабатывает событие) и сервисный cURL-клиент для отправки ответов.

Роут вебхука, проверка Secret Token и защита от дублей update_id

Telegram Bot API при установке вебхука через метод setWebhook позволяет передать параметр secret_token. Telegram будет отправлять этот токен в каждом HTTP-запросе в заголовке X-Telegram-Bot-Api-Secret-Token. Валидация этого заголовка гарантирует, что запрос действительно пришел от серверов Telegram, а не от злоумышленника.

Второе важное требование — идемпотентность. Если ваш сервер не ответит кодом 200 OK в течение нескольких секунд (из-за сетевой задержки или высокой нагрузки), Telegram повторит отправку того же update_id. Чтобы избежать повторного создания заказов или дублирования сообщений, необходимо атомарно регистрировать полученные update_id в базе данных.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Jobs\ProcessTelegramUpdate;

class TelegramWebhookController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$secretToken = (string) config('services.telegram.webhook_secret');
$headerToken = (string) $request->header('X-Telegram-Bot-Api-Secret-Token');

if (empty($secretToken) || !hash_equals($secretToken, $headerToken)) {
return response()->json(['error' => 'Unauthorized'], 401);
}

$payload = $request->all();
$updateId = $payload['update_id'] ?? null;

if (!$updateId) {
return response()->json(['error' => 'Invalid update payload'], 400);
}

// Защита от повторной обработки дублирующихся update_id
$inserted = DB::table('telegram_updates')->insertOrIgnore([
'update_id' => $updateId,
'created_at' => now(),
]);

if (!$inserted) {
return response()->json(['status' => 'already_processed'], 200);
}

// Передаем обновление в очередь для мгновенного освобождения HTTP-потока
ProcessTelegramUpdate::dispatch($payload);

return response()->json(['status' => 'ok'], 200);
}
}

Обработка входящих обновлений в очереди Laravel Queue

Внутри задачи ProcessTelegramUpdate разбирается контекст входящего сообщения или нажатия кнопки. При обработке команд deep-linking (формата /start payload, например t.me/YourBot?start=ref123) параметр передается через пробел после команды /start.

При создании записей в БД (например, карточки лида или заявки) генерируйте уникальные безопасные идентификаторы с помощью bin2hex(random_bytes(7)). Также помните об ограничении размера поля callback_data у Inline-кнопок: оно не может превышать 64 байта, поэтому передавать сложные JSON-структуры в кнопки нельзя — передавайте только короткие ID.

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use App\Services\TelegramClient;

class ProcessTelegramUpdate implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public function __construct(
public array $update
) {}

public function handle(TelegramClient $client): void
{
if (isset($this->update['message'])) {
$message = $this->update['message'];
$chatId = $message['chat']['id'] ?? null;
$text = trim($message['text'] ?? '');

if (!$chatId) {
return;
}

// Обработка стартовой команды и deep-link
if (str_starts_with($text, '/start')) {
$parts = explode(' ', $text, 2);
$refCode = $parts[1] ?? null;

$leadId = bin2hex(random_bytes(7));

DB::table('leads')->insert([
'lead_id' => $leadId,
'telegram_id' => $message['from']['id'],
'ref_code' => $refCode,
'created_at' => now(),
]);

$client->sendMessage([
'chat_id' => $chatId,
'text' => "Заявка #{$leadId} принята! Мы скоро свяжемся с вами.",
'parse_mode' => 'HTML',
]);
}
}
}
}

Реализация сервиса TelegramClient на cURL

Для прямого взаимодействия с HTTP API Telegram напишем клиент TelegramClient. Нам не нужны сторонние пакеты: чистый cURL с валидацией HTTP-статуса, обработкой json_last_error() и проверкой атрибута ok === true гарантирует предсказуемое поведение и удобное логирование.

<?php

namespace App\Services;

use RuntimeException;

class TelegramClient
{
private string $token;
private string $apiUrl;

public function __construct()
{
$this->token = (string) config('services.telegram.bot_token');
$this->apiUrl = "https://api.telegram.org/bot{$this->token}/";
}

public function sendMessage(array $params): array
{
return $this->request('sendMessage', $params);
}

public function request(string $method, array $params = []): array
{
if (empty($this->token)) {
throw new RuntimeException('Telegram bot token is missing in configuration.');
}

$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $this->apiUrl . $method,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($params),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
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) {
throw new RuntimeException("cURL Error call Telegram API: {$curlError}");
}

$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException("Telegram API invalid JSON: " . json_last_error_msg());
}

if ($httpCode !== 200 || !isset($data['ok']) || $data['ok'] !== true) {
$description = $data['description'] ?? 'Unknown error';
$errorCode = $data['error_code'] ?? $httpCode;
throw new RuntimeException("Telegram API Error [{$errorCode}]: {$description}");
}

return $data['result'];
}
}

Тестирование Webhook и логики через фейковые Update

Главный плюс такой архитектуры — легкая подменяемость и изоляция в автотестах Laravel. Вы можете протестировать обработку вебхука без обращения к серверам Telegram, фейкуя очередь задач Queue::fake() и проверяя записи в базе данных.

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use App\Jobs\ProcessTelegramUpdate;

class TelegramWebhookTest extends TestCase
{
use RefreshDatabase;

public function test_webhook_accepts_valid_request_and_dispatches_job(): void
{
Queue::fake();

config(['services.telegram.webhook_secret' => 'test-secret-key']);

$payload = [
'update_id' => 88776655,
'message' => [
'message_id' => 101,
'from' => ['id' => 999111, 'is_bot' => false, 'first_name' => 'Ivan'],
'chat' => ['id' => 999111, 'type' => 'private'],
'text' => '/start partner_abc',
],
];

$response = $this->withHeaders([
'X-Telegram-Bot-Api-Secret-Token' => 'test-secret-key',
])->postJson('/api/telegram/webhook', $payload);

$response->assertStatus(200);
$response->assertJson(['status' => 'ok']);

$this->assertDatabaseHas('telegram_updates', [
'update_id' => 88776655,
]);

Queue::assertPushed(ProcessTelegramUpdate::class, function ($job) use ($payload) {
return $job->update['update_id'] === $payload['update_id'];
});
}

public function test_webhook_blocks_unauthorized_requests(): void
{
config(['services.telegram.webhook_secret' => 'correct-token']);

$response = $this->withHeaders([
'X-Telegram-Bot-Api-Secret-Token' => 'invalid-token',
])->postJson('/api/telegram/webhook', ['update_id' => 12345]);

$response->assertStatus(401);
}
}

Отказ от монструозных SDK в пользу собственного подконтрольного cURL-клиента и стандартных механизмов Laravel (Queue, DB, Feature Tests) дает максимальную стабильность, позволяет точечно обрабатывать лимиты Telegram Bot API и упрощает поддержку проекта на любом этапе.

Если вам нужна помощь в разработке отказоустойчивых веб-сервисов и ботов, команда BotCreator поможет реализовать проект любой сложности.

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

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