Обработка веб-перехватчиков Telegram в Laravel: промежуточное ПО, задания в очереди и идемпотентность Redis

Обработка обновлений Telegram внутри HTTP-запроса является хрупкой. Telegram ожидает быструю 200 ОК. Медленная работа с базой данных, внешние API или сложная логика приводят к таймаутам и повторным попыткам — и эти повторные попытки могут запускать одно и то же обновление дважды.

Твердый стек веб-перехватчиков Laravel делает четыре вещи: проверяет секретный токен, быстро подтверждает, дедуплицирует update_idи обрабатывать работу в очереди.

Это руководство описывает этот конвейер: промежуточное программное обеспечение для секрета веб-перехватчика, идемпотентность Redis (или DB) и задание в очереди для реальной работы.

Что мы строим (и что пропускаем)

Мы создаем внутренний путь для получения, проверки, дедупликации и постановки в очередь обновлений Telegram. Мы не создаем полноценную диалоговую структуру, длинный опрос или интеграцию мини-приложений.

Шаг 1: Конфигурация и маршрутизация

Храните учетные данные вне источника. Поместите их в Защита экологии и сопоставить их через конфигурацию Laravel.

TELEGRAM_BOT_TOKEN=123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ
TELEGRAM_WEBHOOK_SECRET=a_secure_random_string_here

Зарегистрируйте их в config/services.php:

return [
// ... other services
'telegram' => [
'token' => env('TELEGRAM_BOT_TOKEN'),
'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET'),
],
];

Telegram публикует обновления как СТОЙКА. Определите маршрут в routes/api.php (или routes/web.php с исключением CSRF) и прикрепите промежуточное программное обеспечение:

use App\Http\Controllers\TelegramWebhookController;
use App\Http\Middleware\VerifyTelegramSecret;
use Illuminate\Support\Facades\Route;

Route::post('/telegram/webhook', TelegramWebhookController::class)
->middleware(VerifyTelegramSecret::class);

Шаг 2. Промежуточное ПО для секретного токена

Когда вы позвоните setWebhook, пройдите a секретный токен. Затем Telegram отправляет это значение при каждом запросе в X-Telegram-Bot-Api-Secret-Token header. Отклоните все, что не соответствует.

php artisan make:middleware VerifyTelegramSecret

Внедрите его в app/Http/Middleware/VerifyTelegramSecret.php;:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyTelegramSecret
{
public function handle(Request $request, Closure $next): Response
{
$expectedSecret = config('services.telegram.webhook_secret');

if (empty($expectedSecret)) {
return response()->json(['error' => 'Webhook secret is not configured.'], 500);
}

$providedSecret = $request->header('X-Telegram-Bot-Api-Secret-Token');

if (!$providedSecret || !hash_equals($expectedSecret, $providedSecret)) {
return response()->json(['error' => 'Unauthorized.'], 403);
}

return $next($request);
}
}

Использование hash_equals поэтому сравнение безопасно с точки зрения времени.

Шаг 3: Контроллер и идемпотентность

Каждое обновление имеет уникальную update_id. Если вы отвечаете медленно, Telegram может повторно отправить ту же полезную нагрузку. Дедуплицируйте, прежде чем ставить работу в очередь.

С помощью Redis установите ключ с TTL, используя NX (только если отсутствует). Если ключ уже существует, верните 200 и пропустите задание.

php artisan make:controller TelegramWebhookController --invokable
<?php

namespace App\Http\Controllers;

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

class TelegramWebhookController
{
public function __invoke(Request $request): JsonResponse
{
$payload = $request->all();

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

$updateId = (int) $payload['update_id'];
$redisKey = "telegram:update:{$updateId}";

// 24h TTL; set only if the key does not exist
$isUnique = Redis::connection()->client()->set(
$redisKey,
'1',
'EX',
86400,
'NX'
);

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

ProcessTelegramUpdate::dispatch($payload);

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

Без Redis, вставьте update_id в таблицу с уникальным ограничением. Поймайте ошибку дубликата ключа и все равно верните 200.

Шаг 4. Задание в очереди

Задание анализирует обновление, запускает бизнес-логику и вызывает API бота. Держите HTTP-отклик светлым; сделайте работу здесь.

php artisan make:job ProcessTelegramUpdate
<?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\Http;
use Illuminate\Support\Facades\Log;
use Throwable;

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

public int $tries = 3;
public int $backoff = 5;

public function __construct(protected array $payload)
{
}

public function handle(): void
{
if (isset($this->payload['message']['text'])) {
$chatId = $this->payload['message']['chat']['id'];
$text = $this->payload['message']['text'];

if (str_starts_with($text, '/start')) {
$this->sendTextMessage($chatId, 'Hello! Welcome to our bot.');
}
}

if (isset($this->payload['callback_query'])) {
$callbackQuery = $this->payload['callback_query'];
$callbackQueryId = $callbackQuery['id'];
$data = $callbackQuery['data'] ?? '';

$this->answerCallbackQuery($callbackQueryId, 'Action received: ' . $data);
}
}

protected function sendTextMessage(int $chatId, string $text): void
{
$token = config('services.telegram.token');
$url = "https://api.telegram.org/bot{$token}/sendMessage";
$safeText = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');

$response = Http::timeout(10)
->post($url, [
'chat_id' => $chatId,
'text' => $safeText,
'parse_mode' => 'HTML',
]);

if ($response->failed()) {
Log::error('Telegram API Error', [
'status' => $response->status(),
'body' => $response->body(),
]);
throw new \RuntimeException('Failed to send Telegram message');
}

$responseData = $response->json();
if (!($responseData['ok'] ?? false)) {
Log::error('Telegram returned ok=false', ['response' => $responseData]);
throw new \RuntimeException('Telegram API returned success status false');
}
}

protected function answerCallbackQuery(string $callbackQueryId, string $text): void
{
$token = config('services.telegram.token');
$url = "https://api.telegram.org/bot{$token}/answerCallbackQuery";

Http::timeout(5)->post($url, [
'callback_query_id' => $callbackQueryId,
'text' => $text,
'show_alert' => false,
]);
}

public function failed(Throwable $exception): void
{
Log::error('Telegram update processing failed permanently', [
'update_id' => $this->payload['update_id'] ?? null,
'exception' => $exception->getMessage(),
]);
}
}

Производственные заметки

Ограничение данных обратного вызова

callback_data имеет колпачок при 64 Байта. Не набивайте большие JSON на кнопки. Сохраните состояние в Redis или базе данных и введите короткий идентификатор в кнопку.

Ограничения скорости и повторные попытки

Telegram ограничивает исходящий трафик (примерно 30 сообщений в секунду по всему миру, около 1 в секунду на чат). По HTTP 429чтение, @ item: intext Access permission, concatenated Повторное соединение после: (или parameters.retry_after) и отпустите задание с этой задержкой.

Состояние записи перед подтверждением

Для платежей или лидов зафиксируйте запись в базу данных перед отправкой подтверждения. Пример:

$leadId = bin2hex(random_bytes(7));

DB::table('leads')->insert([
'lead_id' => $leadId,
'chat_id' => $chatId,
'created_at' => now(),
]);

$this->sendTextMessage($chatId, "Your lead ID is: <b>{$leadId}</b>");

Если вставка не срабатывает, задание не срабатывает, и пользователь никогда не получает поддельного подтверждения.

Нужна студия, которая поставляет этот вид стека веб-перехватчиков для ботов и мини-приложений? Начало в botservice.biz.

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

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