Начинаем общение

Несмотря на небольшой, хоть и понятно почему, отклик аудитории, я продолжнаю писать заметки по работе с Vesp на примере бота для Телеграм.

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

Поэтому сегодня мы создаём таблицы в БД и сохраняем в них что-то, что будем потом выводить.

Для генерации тестовых данных очень удобно использовать библиотеку fakerphp/faker библиотеку. Мы же используем её вариацию pelmered/fake-car, для генерации данных автомобилей.

composer require pelmered/fake-car

Дальше мы уже начинаем использовать базу данных, поэтому нужно задать параметры подключения к ней в нашем файле .env.local:

DB_DRIVER=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_PREFIX=app_
DB_DATABASE=ExampleBot
DB_USERNAME=root
DB_PASSWORD=root
DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_general_ci

Теперь можно создать стандартные таблицы и юзеров:

composer db:migrate
composer db:seed

Если здесь нет никаких ошибок - это хорошо, можно продолжать. Пишем новую миграцию.

cd core/
./vendor/bin/phinx create Vehicles

Это создаст файл миграции с датой и временем, который нужно отредактировать вот так:

<?php

use Illuminate\Database\Schema\Blueprint;
use Vesp\Services\Migration;

final class Vehicles extends Migration
{
    // Действия при миграции
    public function up(): void
    {
        // Создаём новую таблицу с автомобилями
        $this->schema->create('vehicles', static function (Blueprint $table) {
            $table->id(); 
            $table->string('brand'); // Брэнд автомобиля
            $table->string('model'); // Модель
            $table->string('type'); // Тип: седан, хэтчбэк
            $table->string('fuel'); // дизель, бензин, электро
            $table->tinyInteger('doors'); // количество дверей
            $table->tinyInteger('seats'); // количество седений
            $table->string('gearbox'); // тип коробки передач
            $table->json('properties')->nullable(); // кожаные сиденья и прочее
            $table->boolean('active')->default(true); // Выводить ли запись
            $table->timestamps(); // Даты создания и редактирования
        });
    }

    // Удаление таблицы ари откате
    public function down(): void
    {
        $this->schema->drop('vehicles');
    }
}

Вот такая у нас будет миграция для новых данных, можно сразу же здесь её и запустить:

./vendor/bin/phinx migrate

Обратите внимание, что все работы с Phinx производятся из директории core, потому что в ней лежит её конфиг - phinx.php.

Таблица готова, теперь нужно создать модель в core/src/Models/Vehicle.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

/**
 * Перечисление всё тех же полей таблицы с типами
 * @property int $id
 * @property string $brand
 * @property string $model
 * @property string $type
 * @property string $fuel
 * @property int $doors
 * @property int $seats
 * @property string $gearbox
 * @property ?array $properties
 * @property bool $active
 */
class Vehicle extends Model
{
    // Эти поля нельзя указывать массовом заполнении
    protected $guarded = ['id', 'created_at', 'updated_at'];
    // Эти поля будут приобразоываться в указанные типы
    protected $casts = [
        'properties' => 'json',
        'active' => 'bool',
    ];
}

Подробнее про модели Eloquent и работу с ними смотрите в документации.

Теперь мы можем работать с новой таблицей через новую модель, чем и воспользуемся. Пишем скрипт для заливки фейковых данных в core/db/seeds/Vehicles.php:

<?php

use App\Models\Vehicle;
use Faker\Factory;
use Faker\Provider\Fakecar;
use Phinx\Seed\AbstractSeed;

class Vehicles extends AbstractSeed
{
    public function run(): void
    {
        // Запускаем библиотеку
        $faker = Factory::create();
        // Добавляем провайдера данных
        $faker->addProvider(new Fakecar($faker));

        // Забиваем тысячу записей в БД
        for ($i = 0; $i < 1000; $i++) {
            $vehicle = [
                'brand' => $faker->vehicleBrand,
                'model' => $faker->vehicleModel,
                'type' => $faker->vehicleType,
                'fuel' => $faker->vehicleFuelType,
                'doors' => $faker->vehicleDoorCount,
                'seats' => $faker->vehicleSeatCount,
                'gearbox' => $faker->vehicleGearBoxType,
                'properties' => $faker->vehicleProperties ?: null,
            ];

            // Собстаенно, вот здесь идёт сохранение через модель
            Vehicle::query()->create($vehicle);
        }
    }
}

Теперь нужно только запустить конкретно этот скрипт из core:

./vendor/bin/phinx seed:run -s Vehicles

Имейте в виду, что 1000 новых машин будет добавляться при каждом запуске! Но вы всегда может удалить новую таблицу со всем содержимым через

./vendor/bin/phinx rollback

А потом снова migrate и seed данные. Самое сложное позади, продолжаем работу с ботом.

Вывод каталога

Я решил, что при запуске /catalogue в боте мы будем получать все доступные марки автомобилей, в виде кнопочек, а затем кликая по ним просматривать конкретные модели.

Для этого в команде нам нужны раздельно методы вывод всех названий брендов и их марок с фильтрацией по бренду.

Но тонкость в том, что нажатия на кнопочки должны отправлять какую-то команду боту, и в ней нужно указать что именно делать, строкой. longman/telegram-bot обрабатывает все подобные действия в одной системной команде CallbackqueryCommand, и нам нужно её переопределить.

Дальше может быть немного запутано, но просто дочитайте до конца заметки, и у вам сложится понимание, как работает inline клавиатура у боте.

Создаём core/src/Commands/CallbackqueryCommand.php:

<?php

namespace App\Commands;

use Longman\TelegramBot\Entities\ServerResponse;
use Longman\TelegramBot\Request;

class CallbackqueryCommand extends \Longman\TelegramBot\Commands\SystemCommands\CallbackqueryCommand
{
    public function execute(): ServerResponse
    {
        // Получаем объект callback query из API Telegram
        $callback = $this->getCallbackQuery();
        // И сообщение, в котором нажали на кнопку
        $message = $callback->getMessage();

        // Здесь мы получаем строку с командой и разбиваем её на состовляющие через разделитель '::'
        if ($data = explode('::', $callback->getData())) {
            // Первым должно идти имя команды
            $command = array_shift($data);
            // Оно начинается со слэша
            if (strpos($command, '/') === 0) {
                // И дальше пусть уже сама команда разбирается с запросом
                return $this->getTelegram()->executeCommand(substr($command, 1));
            }
        }

        // Если указаной команды нет, или действие неправильно уазано
        // просто возвращаем в чат что нам пришло, для отладки 
        return Request::sendMessage([
            'chat_id' => $message->getChat()->getId(),
            'text' => $data,
        ]);
    }
}

Грубо говоря, получился универсальный контроллер для всех нажатий на inline клавиатуру в проекте, который будет вызывать нужную команду для обработки. Так что к этому файлу нам больше возвращаться не нужно.

Теперь пишем собственно команду для работы с каталогом в core/src/Commands/CatalogueCommand.php. Я приведу немного сокращённый код с комментриями, а полный будет как обычно в репозитории:

<?php

namespace App\Commands;

use App\Models\Vehicle;
use Longman\TelegramBot\Commands\UserCommand;
use Longman\TelegramBot\Entities\InlineKeyboard;
use Longman\TelegramBot\Entities\ServerResponse;
use Longman\TelegramBot\Entities\Update;
use Longman\TelegramBot\Request;
use Longman\TelegramBot\Telegram;
use Vesp\Services\Eloquent;

class CatalogueCommand extends UserCommand
{
    protected $name = 'catalogue';
    protected $description = 'Работа с каталогом товаров';
    protected $usage = '/catalogue';

    public function __construct(Telegram $telegram, ?Update $update = null)
    {
        parent::__construct($telegram, $update);
        // Так как это не контроллер Vesp, подключаться к базе данных приходится вручную, по старинкн
        new Eloquent(); 
    }


    // Основной публичный метод для работы
    public function execute(): ServerResponse
    {
        // Проверяем наличие callback query
        // Если есть - то нужно обработать пришедшую команду от кнопки
        if ($callback = $this->getCallbackQuery()) {
            $message = $callback->getMessage();

            // Разбираем команду на части, отбрасывая первую - мы ужеи так уже в нужном классе
            $data = array_slice(explode('::', $callback->getData()), 1);
            // Дальше идёт запрос метода этого класса, например 'getBrand'
            $method = array_shift($data);
            // Если он есть - вызываем его
            $response = $method && method_exists($this, $method)
                ? $this->$method(...$data)
                // А если нет - то выводим все бренды
                : $this->getBrands();

            // Метод должен вернуть array или null
            if (is_array($response)) {
                // Если пришёл массив, то всё в порядке, добавляем в него параметры
                $response['chat_id'] = $message->getChat()->getId();
                $response['message_id'] = $message->getMessageId();

                // Чтобы изменить сообщение с клавиатурой
                // Изменить, не написать новое!
                return Request::editMessageText($response);
            }

            // А если пришёл null, то это ошибка, надо бы её залогировать, но нам пока некуда
            return Request::sendMessage([
                'chat_id' => $message->getChat()->getId(),
                'message_id' => $message->getMessageId(),
                'text' => 'Какая-то ошибка, увы...',
            ]);
        }

        // Если же никакого callback query нет, то просто выводим бренды новым сообщением
        return $this->replyToChat('', $this->getBrands());
    }

    // Метод для выборки всех брендов
    protected function getBrands(): array
    {
        // Тут магия Eloquent
        $brands = Vehicle::query()
            ->where('active', true)
            ->groupBy('brand')
            ->orderBy('brand')
            // пока просто лимит, а должна быть постраничная навигация, по идее
            ->limit(30)
            // Группируем, сортируем и выбираем только бренд
            ->pluck('brand')
            ->toArray();

        // А вот здесь мы генерируем кнопочки клавиатуры для брендов согласно документации Telegram
        $rows = [];
        foreach ($brands as $brand) {
            $rows[] = [
                'text' => $brand,
                // Собственно, действие при нажатии, которое потом будем разбирать
                // Здесь вывод всех автомобилей одного бренда
                'callback_data' => implode('::', [$this->usage, 'getBrand', $brand]),
            ];
        }

        // Метод prepareKeyboard разделяет простой массив с кнопками на чуть более сложный
        // чтобы уместить по несколько кнопок в ряд, если нужно
        return [
            'text' => 'Выберите бренд',
            'reply_markup' => new InlineKeyboard(...$this->prepareKeyboard($rows)),
        ];
    }

    // Тут мы будем выбирать все модели одного бренда
    protected function getBrand($brand): array
    {
        $vehicles = Vehicle::query()
            ->where(['brand' => $brand, 'active' => true])
            ->orderBy('id')
            ->limit(10);

        $rows = [
            // Добавляем действия по возврату назад, а точнее просто выводу всего каталога
            ['text' => 'Вернуться назад', 'callback_data' => $this->usage],
        ];
        /** @var Vehicle $vehicle */
        foreach ($vehicles->get() as $vehicle) {
            // И набиваем новые кнопки для вывод конкретной модели
            $rows[] = [
                'text' => $vehicle->brand . ' ' . $vehicle->model,
                'callback_data' => implode('::', [$this->usage, 'getVehicle', $vehicle->id]),
            ];
        }

        return [
            'text' => 'Выберите модель',
            'reply_markup' => new InlineKeyboard(...$this->prepareKeyboard($rows)),
        ];
    }

    // Вывод модели
    // Просто печатаем её параметры с кнопками возврата
    protected function getVehicle($id): ?array
    {
        if (!$vehicle = Vehicle::query()->find($id)) {
            return null;
        }

        /** @var Vehicle $vehicle */
        $rows = [
            [
                'text' => 'Другие модели ' . $vehicle->brand,
                'callback_data' => implode('::', [$this->usage, 'getBrand', $vehicle->brand]),
            ],
            ['text' => 'Обратно в каталог', 'callback_data' => $this->usage],
        ];

        return [
            'text' => print_r($vehicle->toArray(), true),
            'reply_markup' => new InlineKeyboard(...$this->prepareKeyboard($rows)),
        ];
    }

    protected function prepareKeyboard($rows): array
    {
        // Здесь функция по преобразованию простого плоского массива [ кнопка, кнопка, кнопка ] в более сложный
        // [строка [ кнопка, кнопка], новая строка [кнопка], и т.д. ]
        // в зависимости от количества этих кнопок
    }
}

Итого - вся логика по работе с каталогом у нас в одном классе. Основной метод определяет, был ли это просто запрос, или нажата кнопка клавиатуры, и действует в зависимости от этого.

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

Заключение

В логике работы с указаним действия на кнопках с параметрами есть 2 момента, которые лично мне очень нравится.

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

И во-вторых, при нажатии кнопки мы редактируем одно и то же сообщение, что превращает чат в какой-то интерактивный интерфейс для работы. Даже если вы ушли в своей беседе в другой раздел, при нажатии на кнопки в старом сообщении будет обновлено именно оно, на своём месте.

Получется, что с каталогом можно работать как-бы в отдельном окне. Не знаю, кому как, но мне такое кажется очень продвинутым для чата!

Само-собой, для нормальной работы мне пришлось перенести бота на сервер и сделать работу через Webhook вместо консольного скрипта. Вот вам домашнее задание - найдите этот контроллер, и путь к нему, ответ пишите в комментариях.

Если хотите покликать, то адрес прежний - https://t.me/VespExampleBot

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

Увы, но нет, не следует.

Надеюсь, что прочитанной информации вам будет достаточно, чтобы дальше развивать логику чата.

Комментарии (8)
ni.kolokol@mail.ruНиколай Каленников
03.03.2022 16:26

Завораживает) Вроде, этот контроллер

/core/src/Controllers/Web/Webhook.php
bezumkinВасилий Наумкин
03.03.2022 17:00

Верно! А какой для обращения к нему, и какие разрешены протоколы?

https://service.ru/api/дальше/что?
ni.kolokol@mail.ruНиколай Каленников
03.03.2022 17:06

POST на адрес:

https://service.ru/api/web/webhook
bezumkinВасилий Наумкин
03.03.2022 17:11

Так и есть, молодец!

ElectricaМихаил
03.03.2022 16:56

Круто! Спасибо за пример.

Сергей Лелеко
04.03.2022 08:27

А что это? ...$this->prepareKeyboard($rows) клонирование как в vue ?

bezumkinВасилий Наумкин
04.03.2022 08:34

Это передача переменного количества аргументов в функцию из массива.

https://www.php.net/manual/ru/functions.arguments.php#functions.variable-arg-list

Сергей Лелеко
04.03.2022 09:12

О как! не знал! спасибо

bezumkin
Василий Наумкин
09.04.2024 01:45
Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. Во...
futuris
Futuris
04.04.2024 05:56
Я просто немного запутался. Когда в абзаце &quot;Vesp/Core&quot; ты пишешь про &quot;новый trait Fil...
bezumkin
Василий Наумкин
20.03.2024 18:21
Volledig!
Андрей
14.03.2024 10:47
Василий! Как всегда очень круто! Моё почтение!
russelgal
russel gal
09.03.2024 17:17
А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал ...
inetlover
Александр Наумов
27.01.2024 00:06
Василий, спасибо! Извини, тупанул.
bezumkin
Василий Наумкин
22.01.2024 04:43
Давай-давай!
bezumkin
Василий Наумкин
24.12.2023 11:26
Спасибо!
bezumkin
Василий Наумкин
27.11.2023 02:43
Ура!
bezumkin
Василий Наумкин
25.11.2023 08:30
Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда. Их можно обновлять, но э...