PHP и фреймворки
БД и состояния (FSM)
Практическое руководство по хранению состояний (FSM) и работе с БД в Telegram-ботах на PHP: схема таблицы, PDO-пример с telegramApi(), Laravel/Eloquent вариант, нюансы конкурентности и TTL.
Когда бот выходит за рамки простых команд «запрос — ответ», появляется необходимость хранить данные: настройки пользователей, корзины, прогресс многошаговых сценариев. Для этого нужны БД и конечный автомат (FSM). В главе Первый запрос: getMe мы сделали функцию telegramApi() — здесь используем её для отправки сообщений внутри FSM-хендлеров.
Зачем БД в боте
Telegram не хранит контекст диалогов за вас. chat_id и user_id — единственные стабильные идентификаторы (подробнее в главе chat_id и user_id). Всё остальное — ваша ответственность:
- Профили пользователей: язык, таймзона, согласия на рассылки.
- Состояние FSM: на каком шаге сценария находится пользователь.
- Бизнес-данные: корзина, черновики постов, выбранные фильтры.
- Логи и аналитика:
update_id, входящиеcallback_data, ошибки.
Для небольших проектов достаточно SQLite, для продакшена — PostgreSQL или MySQL. В Laravel используйте Eloquent, в чистом PHP — PDO с подготовленными выражениями.
FSM: паттерн для многошаговых сценариев
FSM (Finite State Machine) моделирует диалог как набор состояний и переходов. Пример: оформление заказа — idle → awaiting_address → awaiting_phone → confirming → completed.
Храните текущее состояние в БД, привязанное к user_id (или chat_id для групп). При каждом update читайте состояние, решаете, какой хендлер вызвать, пишете новое состояние. В главе Long polling мы сделали функцию processUpdate() — туда и встраивается вызов FSM-диспетчера.
Схема таблицы состояний
CREATE TABLE user_states (
user_id BIGINT PRIMARY KEY,
state VARCHAR(64) NOT NULL DEFAULT 'idle',
payload JSON DEFAULT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Поле payload (JSON) хранит промежуточные данные шага: выбранный товар, введённый адрес, ID сообщения для редактирования (пригодится для safeEditMessageText из главы Редактирование и удаление).
Минимальный FSM-хендлер на PHP (PDO + telegramApi)
Используем функцию telegramApi(), которую мы сделали в главе Первый запрос: getMe. Здесь только вызываем её — реализацию не копируем.
<?php
require 'vendor/autoload.php';
$pdo = new PDO('sqlite:bot.db');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// --- Вспомогательные функции БД ---
function getState(PDO $pdo, int $userId): array {
$stmt = $pdo->prepare('SELECT state, payload FROM user_states WHERE user_id = ?');
$stmt->execute([$userId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: ['state' => 'idle', 'payload' => null];
}
function setState(PDO $pdo, int $userId, string $state, ?array $payload = null): void {
$json = $payload ? json_encode($payload, JSON_UNESCAPED_UNICODE) : null;
$pdo->prepare(
'INSERT INTO user_states (user_id, state, payload) VALUES (?, ?, ?)'
. ' ON CONFLICT(user_id) DO UPDATE SET state = excluded.state, payload = excluded.payload, updated_at = CURRENT_TIMESTAMP'
)->execute([$userId, $state, $json]);
}
// --- FSM-диспетчер ---
$update = json_decode(file_get_contents('php://input'), true);
if (!$update) exit;
// Удобно смотреть сырые обновления в терминале:
// file_put_contents('php://stderr', print_r($update, true));
$message = $update['message'] ?? ($update['callback_query']['message'] ?? null);
if (!$message) exit;
$userId = $message['from']['id'];
$chatId = $message['chat']['id'];
$text = $message['text'] ?? '';
$callbackData = $update['callback_query']['data'] ?? null;
$current = getState($pdo, $userId);
$payload = $current['payload'] ? json_decode($current['payload'], true) : [];
// Логируем текущее состояние для отладки:
// error_log("FSM: user={$userId} state={$current['state']} payload=" . json_encode($payload));
switch ($current['state']) {
case 'idle':
if ($text === '/order') {
sendMessage($chatId, 'Введите адрес доставки:');
setState($pdo, $userId, 'awaiting_address');
}
break;
case 'awaiting_address':
$payload['address'] = $text;
sendMessage($chatId, "Адрес принят: <b>{$text}</b>. Введите телефон:");
setState($pdo, $userId, 'awaiting_phone', $payload);
break;
case 'awaiting_phone':
$payload['phone'] = $text;
sendMessage($chatId, "Проверьте данные:\n<b>Адрес:</b> {$payload['address']}\n<b>Телефон:</b> {$text}\nПодтвердить?", [
'reply_markup' => json_encode([
'inline_keyboard' => [
[['text' => '✅ Да', 'callback_data' => 'order_confirm']],
[['text' => '❌ Нет', 'callback_data' => 'order_cancel']],
]
])
]);
setState($pdo, $userId, 'confirming', $payload);
break;
case 'confirming':
if ($callbackData === 'order_confirm') {
// Тут сохраняем заказ в БД
setState($pdo, $userId, 'idle');
// Отвечаем на callback_query (убираем "часики"):
telegramApi('answerCallbackQuery', ['callback_query_id' => $update['callback_query']['id']]);
sendMessage($chatId, '✅ Заказ оформлен! Менеджер свяжется с вами.');
} elseif ($callbackData === 'order_cancel') {
setState($pdo, $userId, 'idle');
telegramApi('answerCallbackQuery', ['callback_query_id' => $update['callback_query']['id']]);
sendMessage($chatId, '❌ Отменено.');
}
break;
}
// --- Обёртка sendMessage использует telegramApi() из главы "Первый запрос: getMe" ---
function sendMessage(int $chatId, string $text, array $options = []): array {
return telegramApi('sendMessage', array_merge([
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML'
], $options));
}
Пример ответа sendMessage и как его посмотреть
Функция telegramApi возвращает поле result — объект Message. Чтобы увидеть структуру в терминале:
$sent = sendMessage($chatId, 'Тест');
// В консоли (php script.php) или логах:
var_dump($sent);
// Или красиво:
echo json_encode($sent, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
{
"message_id": 123,
"date": 1700000000,
"chat": {
"id": 123456789,
"type": "private",
"first_name": "Ivan"
},
"text": "Тест"
}
Что смотреть: message_id — нужен для последующего редактирования/удаления; chat.id — подтверждает, что сообщение ушло туда, куда надо; date — unix-время отправки.
FSM в Laravel (Eloquent)
В Laravel удобно оформить FSM как отдельный сервис с Eloquent-моделью UserState. Используйте updateOrCreate для атомарного сохранения. Хендлеры выносите в отдельные классы (Action-паттерн), диспетчерите по полю state. Middleware может автоматически подгружать состояние в $request->userState.
Используем сервис TelegramBot из главы Laravel для исходящих запросов. Здесь только вызываем его методы — реализацию не копируем.
<?php
namespace App\Services;
use App\Models\UserState;
use Illuminate\Support\Facades\Http;
class FsmService
{
public function getState(int $userId): array
{
$record = UserState::find($userId);
return $record ? ['state' => $record->state, 'payload' => $record->payload] : ['state' => 'idle', 'payload' => []];
}
public function setState(int $userId, string $state, array $payload = []): void
{
UserState::updateOrCreate(
['user_id' => $userId],
['state' => $state, 'payload' => $payload]
);
}
public function handle(int $userId, int $chatId, ?string $text, ?string $callbackData): void
{
$current = $this->getState($userId);
$payload = $current['payload'] ?? [];
match ($current['state']) {
'idle' => $text === '/order' && $this->startOrder($chatId, $userId),
'awaiting_address' => $this->saveAddress($chatId, $userId, $text, $payload),
'awaiting_phone' => $this->askConfirm($chatId, $userId, $text, $payload),
'confirming' => $callbackData && $this->processConfirm($chatId, $userId, $callbackData),
default => null,
};
}
private function startOrder(int $chatId, int $userId): void
{
$this->send($chatId, 'Введите адрес доставки:');
$this->setState($userId, 'awaiting_address');
}
private function saveAddress(int $chatId, int $userId, string $text, array $payload): void
{
$payload['address'] = $text;
$this->send($chatId, "Адрес принят: <b>{$text}</b>. Введите телефон:");
$this->setState($userId, 'awaiting_phone', $payload);
}
private function askConfirm(int $chatId, int $userId, string $text, array $payload): void
{
$payload['phone'] = $text;
$this->send($chatId, "Проверьте данные:\n<b>Адрес:</b> {$payload['address']}\n<b>Телефон:</b> {$text}\nПодтвердить?", [
'reply_markup' => json_encode([
'inline_keyboard' => [
[['text' => '✅ Да', 'callback_data' => 'order_confirm']],
[['text' => '❌ Нет', 'callback_data' => 'order_cancel']],
]
])
]);
$this->setState($userId, 'confirming', $payload);
}
private function processConfirm(int $chatId, int $userId, string $callbackData): void
{
if ($callbackData === 'order_confirm') {
// Сохраняем заказ в БД
$this->setState($userId, 'idle');
// Отвечаем на callback_query:
app(\App\Services\TelegramBot::class)->answerCallbackQuery($update['callback_query']['id'] ?? '');
$this->send($chatId, '✅ Заказ оформлен! Менеджер свяжется с вами.');
} elseif ($callbackData === 'order_cancel') {
$this->setState($userId, 'idle');
app(\App\Services\TelegramBot::class)->answerCallbackQuery($update['callback_query']['id'] ?? '');
$this->send($chatId, '❌ Отменено.');
}
}
private function send(int $chatId, string $text, array $options = []): array
{
return app(\App\Services\TelegramBot::class)->sendMessage($chatId, $text, $options);
}
}
Важные нюансы
- Конкурентность: пользователь может прислать два сообщения почти одновременно. Используйте транзакции БД или
SELECT ... FOR UPDATE, чтобы не потерять переход состояния. - TTL состояний: добавляйте cron-задачу (Laravel Scheduler), сбрасывающую зависшие состояния (например, старше 24 часов) в
idle. - Callback-кнопки: в
callback_dataкладите только идентификатор действия (order_confirm), а не весь контекст — контекст уже вpayloadв БД. - Отладка: пишите
error_logсuser_id,state,payloadна каждом переходе — так вы быстро найдёте, где сломался сценарий.
Дальше: Что такое Mini App — веб-приложения внутри Telegram с доступом к данным пользователя и нативным UI.