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.