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

Обработка обновлених Telegram-повідомлень всередині HTTP-запросу є хрупкою. Telegram очікує швидкого 200 OK. Впроваджене повільне робовання з базою даних, зовнішні API або складна логіка призводять до таймаутів та повторних спроб — а ці повторні спроби можуть запустити одне й те саме оновлення двічі.

\n

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

\n

Це посібник описує цей конвейер: проміжне програмне забезпечення для секретного токена веб-перекидчика, ідемпотентність Redis (або DB) та розміщення у очерті для реальної роботи.

\n\n

Що ми будуємо (і що пропускаємо)

\n

Ми створюємо внутрішній шлях для отримання, перевірки, дедуплікації та розміщення в очерті оновлень Telegram. Ми не створюємо повну діалогову структуру, довгий опитування чи інтеграцію міні-прикладів.

\n\n

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

\n

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

\n
TELEGRAM_BOT_TOKEN=123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ
TELEGRAM_WEBHOOK_SECRET=a_secure_random_string_here
\n

Реєструйте їх у config/services.php:

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

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

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

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

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

\n

Коли ви викликаєте setWebhook, ви проходите через секретний токен. Потім Telegram надсилає цю значення на кожен запит у X-Telegram-Bot-Api-Secret-Token header. Відкиньте все, що не відповідає.

\n
php artisan make:middleware VerifyTelegramSecret
\n

Інтегруйте його у app/Http/Middleware/VerifyTelegramSecret.php;:

\n
<?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);
}
}
\n

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

\n\n

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

\n

Кожне оновлення має унікальний update_id. Якщо ви відповідаєте повільно, Telegram може повторити ту саму полезну нагадування. Дедуплікуйте, перш ніж ставити роботу в очерті.

\n

З використанням Redis встановіть ключ з TTL, використовуючи NX (тільки якщо його немає). Якщо ключ вже існує, поверніть 200 і пропустіть завдання.

\n
php artisan make:controller TelegramWebhookController --invokable
\n
<?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);
}
}
\n

Без Redis вставте update_id у таблицю з унікальним обмеженням. Вловить помилку дублювання ключа і все ж поверніть 200.

\n\n

Шаг 4. Задання в очерті

\n

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

\n
php artisan make:job ProcessTelegramUpdate
\n
<?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(),
]);
}
}
\n\n

Производційні замітки

\n

Обмеження даних обратного виклику

\n

callback_data має колапс при 64 Байт. Не наповнюйте великі JSON у кнопки. Зберігайте стан у Redis або базі даних і введайте короткий ідентифікатор у кнопку.

\n\n

Обмеження швидкості та повторні спроби

\n

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

\n\n

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

\n

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

\n
$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>");
\n

Якщо вставка не працює, завдання не працює, і користувач ніколи не отримує фальшивого підтвердження.

\n\n

Потрібна студія, яка постачає цей тип стеку веб-перекидчиків для ботів та міні-прикладів? Початок у botservice.biz.

"}

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

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