Інтеграція 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

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