PHP і фреймворки

БД і стани (FSM)

Когда бот выходит за рамки простых команд «запрос — ответ», появляется необходимость хранить данные: настройки пользователей, корзины, прогресс многошаговых сценариев. Для этого нужны БД и конечный автомат (FSM).

Зачем БД в боте

Telegram не хранит контекст диалогов за вас. chat_id и user_id — единственные стабильные идентификаторы. Всё остальное — ваша ответственность:

  • Профили пользователей: язык, таймзона, согласия на рассылки.
  • Состояние FSM: на каком шаге сценария находится пользователь.
  • Бизнес-данные: корзина, черновики постов, выбранные фильтры.
  • Логи и аналитика: update_id, входящие callback_data, ошибки.

Для небольших проектов достаточно SQLite, для продакшена — PostgreSQL или MySQL. В Laravel используйте Eloquent, в чистом PHP — PDO с подготовленными выражениями.

FSM: паттерн для многошаговых сценариев

FSM (Finite State Machine) моделирует диалог как набор состояний и переходов. Пример: оформление заказа — idleawaiting_addressawaiting_phoneconfirmingcompleted.

Храните текущее состояние в БД, привязанное к user_id (или chat_id для групп). При каждом update читайте состояние, решаете, какой хендлер вызвать, пишете новое состояние.

Схема таблицы состояний

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 сообщения для редактирования.

Минимальный FSM-хендлер на PHP (PDO)

<?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) : 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]);
}

// Пример обработки
$update = json_decode(file_get_contents('php://input'), true);
$message = $update['message'] ?? $update['callback_query']['message'] ?? null;
if (!$message) exit;

$userId = $message['from']['id'];
$text = $message['text'] ?? '';
$current = getState($pdo, $userId);

switch ($current['state']) {
    case 'idle':
        if ($text === '/order') {
            sendMessage($userId, 'Введите адрес доставки:');
            setState($pdo, $userId, 'awaiting_address');
        }
        break;
    case 'awaiting_address':
        $payload = ['address' => $text];
        sendMessage($userId, 'Адрес принят. Введите телефон:');
        setState($pdo, $userId, 'awaiting_phone', $payload);
        break;
    case 'awaiting_phone':
        $payload = array_merge($current['payload'] ?? [], ['phone' => $text]);
        sendMessage($userId, "Проверьте данные: {$payload['address']}, {$payload['phone']}. Подтвердить? /yes /no");
        setState($pdo, $userId, 'confirming', $payload);
        break;
    case 'confirming':
        if ($text === '/yes') {
            // сохраняем заказ
            setState($pdo, $userId, 'idle');
            sendMessage($userId, 'Заказ оформлен!');
        } elseif ($text === '/no') {
            setState($pdo, $userId, 'idle');
            sendMessage($userId, 'Отменено.');
        }
        break;
}

function sendMessage(int $chatId, string $text): void {
    $token = getenv('BOT_TOKEN');
    $ch = curl_init("https://api.telegram.org/bot{$token}/sendMessage");
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => json_encode(['chat_id' => $chatId, 'text' => $text]),
        CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
    ]);
    $res = curl_exec($ch);
    if ($res === false) {
        error_log('cURL error: ' . curl_error($ch));
    }
    curl_close($ch);
}

FSM в Laravel

В Laravel удобно оформить FSM как отдельный сервис с Eloquent-моделью UserState. Используйте updateOrCreate для атомарного сохранения. Хендлеры выносите в отдельные классы (Action-паттерн), диспетчерите по полю state. Middleware может автоматически подгружать состояние в $request->userState.

Важные нюансы

  • Конкурентность: пользователь может прислать два сообщения почти одновременно. Используйте транзакции БД или SELECT ... FOR UPDATE, чтобы не потерять переход состояния.
  • TTL состояний: добавляйте cron-задачу, сбрасывающую зависшие состояния (например, старше 24 часов) в idle.
  • Callback-кнопки: в callback_data кладите только идентификатор действия (order_confirm), а не весь контекст — контекст уже в БД.
  • Группы: в групповом чате состояние лучше привязывать к паре chat_id + user_id или к message_thread_id для тем.

Готовая реализация FSM есть в пакете botman/botman (драйвер botman/driver-telegram) и в irazasyed/telegram-bot-sdk — они абстрагируют хранение состояний за интерфейсом Conversation.

← Laravel | Дальше: Что такое Mini App →