Пишем Telegram-бота с памятью на PHP/Laravel за выходные

26.06.2026 09:00

Создание Telegram-бота кажется тривиальной задачей. Разработчик регистрирует токен у BotFather, настраивает вебхук и пишет первый if ($text === '/start'). Через два часа бот бодро отвечает на десяток жестко заданных команд. Создается иллюзия продуктивности.

К вечеру воскресенья реальность берет свое. Код превращается в неподдерживаемого монстра. Контроллер обрастает лапшой из вложенных условий, проверок базы данных и глобальных состояний. Пользователь отвечает «Да», а бот ломается, потому что потерял контекст и не понимает, к какому вопросу относится согласие. Попытка добавить нейросеть для связного диалога окончательно добивает проект: контексты запросов смешиваются, медленные ответы API обрывают вебхуки по тайм-ауту, отладка превращается в пытку.

Проблема не в Telegram API и не в ограничениях PHP. Проблема в отсутствии архитектуры. Стейт-машина (конечный автомат) и быстрое хранилище контекста — это грань, отделяющая поделку от стабильного продукта.

Проектируем архитектуру

Мы создадим бота на фреймворке Laravel. Цель — построить систему, которая корректно маршрутизирует команды, хранит историю диалогов и общается с пользователем через LLM.

Ключевые компоненты:

  1. WebhookController: принимает JSON от Telegram и немедленно ставит задачу в очередь.
  2. Стейт-машина: определяет текущее состояние пользователя (idle, registration, chatting) и направляет запрос в специализированный обработчик.
  3. Redis: сверхбыстрое хранилище истории сообщений.
  4. RouterAPI: единый шлюз доступа к нейросетям, интегрированный через пакет openai-php/client.

Изоляция вебхука и очереди

Телеграм требует, чтобы сервер отвечал на вебхук HTTP-статусом 200 в течение нескольких секунд. Запрос к тяжелой языковой модели легко занимает 5–15 секунд. Если вебхук зависнет, Telegram начнет повторять доставку сообщения. Бот сойдет с ума и заспамит пользователя дублями ответов.

Контроллер обязан быть тонким. Он только принимает данные и передает их воркеру.

declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Jobs\ProcessTelegramMessage;

final class TelegramWebhookController extends Controller
{
 public function handle(Request $request)
 {
 $message = $request->input('message');
 
 if ($message && isset($message['chat']['id'], $message['text'])) {
 // Мгновенно ставим задачу в очередь
 ProcessTelegramMessage::dispatch(
 (int) $message['chat']['id'],
 $message['text']
 );
 }

 // Отпускаем сервер Telegram
 return response->json(['status' => 'ok']);
 }
}

Логика переезжает в фоновый процесс, который не боится сетевых задержек.

Диспетчеризация состояний

Воркер извлекает задачу из очереди и передает ее стейт-машине. Вместо парсинга текста регулярными выражениями диспетчер проверяет текущее состояние пользователя.

namespace App\Services\Bot;

use Illuminate\Support\Facades\Redis;
use App\Services\Bot\Handlers\ChatHandler;
use App\Services\Bot\Handlers\IdleHandler;

final class StateDispatcher
{
 public function process(int $chatId, string $text): void
 {
 // Читаем состояние. Если его нет — пользователь в режиме idle
 $state = Redis::get("bot:user:{$chatId}:state") ?? 'idle';

 $handler = match($state) {
 'chatting' => app(ChatHandler::class),
 default => app(IdleHandler::class),
 };

 $handler->handle($chatId, $text);
 }
}

Эта структура изолирует контекст. Обработчик ChatHandler уверен, что пользователь ведет диалог с нейросетью. Ему не нужно проверять системные команды регистрации или настройки профиля — они обрабатываются в других классах.

Управление памятью через Redis

Нейросети не обладают встроенной памятью. Чтобы LLM понимала суть беседы, с каждым новым запросом ей необходимо отправлять историю предыдущих сообщений. Реляционная база данных для этого избыточна: слишком много операций записи и чтения на каждый запрос. Redis справляется с этим мгновенно за счет операций над списками (Lists).

Реализуем сервис памяти:

namespace App\Services\Bot;

use Illuminate\Support\Facades\Redis;

final class ChatMemoryService
{
 private const MAX_MESSAGES = 20;

 public function addMessage(int $chatId, string $role, string $content): void
 {
 $key = "bot:chat:{$chatId}:messages";
 $message = json_encode(['role' => $role, 'content' => $content], JSON_UNESCAPED_UNICODE);
 
 // Добавляем реплику в конец списка
 Redis::rpush($key, $message);
 
 // Отрезаем старые сообщения, оставляя только свежий контекст
 Redis::ltrim($key, -self::MAX_MESSAGES, -1);
 
 // Обновляем время жизни ключа
 Redis::expire($key, 86400);
 }

 public function getContext(int $chatId): array
 {
 $key = "bot:chat:{$chatId}:messages";
 $raw = Redis::lrange($key, 0, -1);
 
 return array_map(fn($msg) => json_decode($msg, true), $raw);
 }
}

Код работает за доли миллисекунды. Приложение всегда имеет актуальный массив реплик, готовый к отправке в API.

Интеграция RouterAPI

Для генерации текста мы применим RouterAPI. Шлюз позволяет обращаться к мощным моделям вроде Claude 3.5 Sonnet или GPT-4o, используя полностью совместимый с OpenAI транспорт. Это освобождает от написания собственных HTTP-клиентов под каждого провайдера.

Устанавливаем официальный пакет: composer require openai-php/client

Настраиваем клиент в сервис-провайдере AppServiceProvider. Главный шаг — переопределить базовый URL и увеличить таймаут, так как ответы сложных моделей могут генерироваться долго.

use OpenAI;
use OpenAI\Client;

public function register: void
{
 $this->app->singleton(Client::class, function {
 return OpenAI::factory
 ->withApiKey(config('services.routerapi.key'))
 ->withBaseUri('https://api.routerapi.net/v1')
 ->withHttpClient(new \GuzzleHttp\Client(['timeout' => 60.0]))
 ->make;
 });
}

Остается связать компоненты в ChatHandler. Этот класс принимает сообщение, обновляет контекст в Redis, отправляет запрос в нейросеть и возвращает результат пользователю.

namespace App\Services\Bot\Handlers;

use App\Services\Bot\ChatMemoryService;
use OpenAI\Client;

final class ChatHandler
{
 public function __construct(
 private readonly ChatMemoryService $memory,
 private readonly Client $llmClient,
 private readonly TelegramApiService $telegram
 ) {}

 public function handle(int $chatId, string $text): void
 {
 $this->memory->addMessage($chatId, 'user', $text);
 
 $messages = $this->memory->getContext($chatId);
 array_unshift($messages, [
 'role' => 'system',
 'content' => 'Ты — технический ассистент. Отвечай точно и без лишней воды.'
 ]);

 $response = $this->llmClient->chat->create([
 'model' => 'claude-3-5-sonnet', 
 'messages' => $messages,
 'max_tokens' => 1500,
 ]);

 $reply = $response->choices[0]->message->content;

 $this->memory->addMessage($chatId, 'assistant', $reply);
 $this->telegram->sendMessage($chatId, $reply);
 }
}

Обратите внимание на чистоту логики. Здесь нет if / else, проверок базы данных или ручной маршрутизации. Класс решает ровно одну задачу.

Итоги

Проектирование системной архитектуры окупается в первые же часы активной разработки. Внедрение очередей предотвращает падение вебхуков, подключение Redis обеспечивает мгновенную работу с памятью диалога, а применение стейт-машины гарантирует изоляцию логики.

Бот перестает забывать контекст диалога. Платформа масштабируется: добавление нового функционала требует написания нового изолированного хэндлера, а не раздувания старого контроллера. Подключение новых LLM через RouterAPI и пакет openai-php сводится к смене конфигурационной строки без вмешательства в транспортный слой.

Выходные подошли к концу. Вместо монолитного легаси-скрипта на сервере развернуто расширяемое production-ready решение.

Теги

Ещё по теме