Начинаем общение
Несмотря на небольшой, хоть и понятно почему, отклик аудитории, я продолжнаю писать заметки по работе с 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
Полный код изменений как обычно в репозитории, ~продолжение следует~.
Увы, но нет, не следует.
Надеюсь, что прочитанной информации вам будет достаточно, чтобы дальше развивать логику чата.
0
👍
👎
❤️
🔥
😮
😢
😀
😡
1 079
03.03.2022 18:28:32
8 комментариев
Николай Каленников
Завораживает) Вроде, этот контроллер
Василий Наумкин
Верно! А какой для обращения к нему, и какие разрешены протоколы?
Николай Каленников
POST на адрес:
Василий Наумкин
Так и есть, молодец!
Михаил
Круто! Спасибо за пример.
Сергей Лелеко
А что это? ...$this->prepareKeyboard($rows) клонирование как в vue ?
Василий Наумкин
Это передача переменного количества аргументов в функцию из массива.
https://www.php.net/manual/ru/functions.arguments.php#functions.variable-arg-list
Сергей Лелеко
О как! не знал! спасибо
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Василий Наумкин
04.02.2025 19:27:08
Я таким давно не занимаюсь и с MODX не работаю.
Попробуйте обратиться к ребятам с modx.pro.
Василий Наумкин
23.12.2024 05:33:00
В MODX сначала создали проблему, автоматически генерируя адреса, а потом "решили" заморозкой.
Так ч...
Дмитрий
14.12.2024 09:10:38
Василий, прошу прощения, тупанул, не разобрался сразу. Фреймворк отличный! "Чистый лист" на vue, рис...
Василий Наумкин
05.12.2024 20:01:14
В итоге основная ошибка была в неправильном общем root в Nginx, из-за чего запросы не улетали на фай...
Василий Наумкин
01.07.2024 11:56:41
Да, верно, именно так.
А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024 09:38:15
О, точно, вылезает если не залогинен.
Спасибо, исправил!
Уровни подписки
Спасибо!
500 ₽ в месяц
Эта подписка ничего не даёт, просто возможность сказать спасибо за мои заметки. Подписчики отмечаются зелёненьким цветом в комментариях.
Большое спасибо!
1 000 ₽ в месяц
И эта подписка не даёт ничего, кроме оранжевого цвета в комментариях и возможности сказать спасибо, но уже большое!