Финальная часть разработки простого проекта про specialty-кофейни на Кипре. В первой части я рассказал про API микросервис, во второй - про фронтэнд-сайт и теперь - про телеграм-бота.
Изначально перед ботом ставились простые задачи:
/map - карта кофеен
/list - список кофеен
подробности о кофейне
/random - случайная кофейня
поиск кофейни по названию
поиск ближайшей кофейни по своему местоположению или по команде /nearest
В ходе реализации выяснилось, что Telegram не умеет показывать встроенную карту с несколькими маркерами, а также не отправляет местоположение в веб-версии. Поэтому вместо карты пришлось показывать ссылку на сайт с картой, а при вместо ответа при получении пустого местоположения - сообщение-заглушку. Все остальное удалось реализовать.
Код проекта открыт, велкам в пул-реквесты. Адрес сайта - в конце статьи.
Архитектура
После "долгих и тщательных раздумий" основой бота была выбрана библиотека Nutgram: наиболее лёгкая, простая и современная. Бонусом идёт полностью настроенный DI-контейнер, благодараю которому можно забыть о ручной инициализации сервисов и их передаче потребителям.
А использование PHP 8.1 позволило написать чуть меньше кода и получить чуть выше производительность. Promoted properties, readonly и строгая типизация сильно облегчают разработку.
Настройки composer'а максимально облегчены аналогично API. Итоговый composer.json.
Обновления от Telegram приходят на webhook endpoint и раздаются обработчикам определённых команд и типов сообщений. Обработчики отвечают самостоятельно или обращаются к REST API за данными. Дополнительно есть Fallback, Exception и ApiError-обработчики для всяких неожиданностей.
Использование коротких single-action invokable-обработчиков позволило уместить всю логику бота в 23 строки!
Пример команды /nearest:
<?php
declare(strict_types=1);
namespace App\Commands;
use App\Contracts\Command;
use SergiX44\Nutgram\Nutgram;
use SergiX44\Nutgram\Telegram\Types\Keyboard\KeyboardButton;
use SergiX44\Nutgram\Telegram\Types\Keyboard\ReplyKeyboardMarkup;
use SergiX44\Nutgram\Telegram\Types\Message\Message;
final class NearestCommand implements Command
{
public const SEND_TEXT = 'Send location';
public function __invoke(Nutgram $bot): ?Message
{
return $bot->sendMessage('Send your location to find the nearest coffee shop', [
'reply_markup' => ReplyKeyboardMarkup::make(resize_keyboard: true)->addRow(KeyboardButton::make(self::SEND_TEXT, request_location: true)),
]);
}
public static function getName(): string
{
return 'nearest';
}
public static function getDescription(): string
{
return 'Show nearest specialty coffee shop';
}
}
Пример обработчика местоположения:
<?php
declare(strict_types=1);
namespace App\Handlers;
use App\Services\ApiService;
use App\Services\Sender;
use SergiX44\Nutgram\Nutgram;
use SergiX44\Nutgram\Telegram\Types\Message\Message;
final class LocationHandler
{
public function __construct(private readonly ApiService $api, private readonly Sender $sender)
{
}
public function __invoke(Nutgram $bot): ?Message
{
$location = $bot->message()->location;
return $this->sender->sendItem(
$this->api->getNearest((string)$location->latitude, (string)$location->longitude), [
'reply_markup' => ['remove_keyboard' => true],
]
);
}
}
Для проверки легитимности сообщений, а также для валидации даных для поиска, используются такие же короткие и ёмкие middleware.
Конфигурация
Общие параметры и названия секретов - в .env, локальные переопределения - в .env.local
Тесты
Пока руками ¯\_(ツ)_/¯
Но в библиотеке Nutgram есть достаточные возможно написания тестов, это радует.
Мониторинг
Sentry, в .env достаточно указать пустое значение SENTRY_DSN (для наглядности), а фактическое значение записать в секрет.
Деплой
Всё та же платформа Fly.io, но теперь с machines, загружающимися за 300ms. В общем случае это FaaS (serverless), но в моём случае с php-сервером - это всё-таки обычная VM.
Ради интереса использую встроенный php-сервер вместо обычного сочетания php-fpm + nginx/caddy + supervisor. Docker-образ, конечно, стал меньше, но пришлось использовать отдельный роутер:
Для пропуска только POST-запросов к обработчикам бота
Для редиректа dev-домена вида .fly.dev на основной домен
Раздачи статики (robots.txt, favicon.ico и т.д.)
Блокировки всех остальных запросов
Итоговый роутер и Dockerfile (такой же слоёный как в API).
CI/CD
Github Action достаточно прост: получаем ID запущенной машины из flyctl machine list --json
, обновляем её flyctl machine run --id <id>
и обновляем регистрацию вебхука curl -sS ${{ secrets.APP_URL }}/setup.php
.
Все секреты хранятся на платформе хостинга и частично дублируются в GitHub production Environment для регистрации вебхука.
На этом этапе бот работает, размещён в продакшн-окружении и доступен всем пользователям. Проект полностью выполнен :)
Репозиторий бота, сайт https://specialtycoffee.cy/
TODO
Теперь, после запуска проекта в спокойном порядке, можно:
настроить внешний мониторинг доступности
health check'и по настоящему ответу сервисов, а не просто "живучести" порта
оптимизировать сборку с Caddy
попробовать Buildpack
заменить встроенный PHP-сервер бота на что-то более безопасное
добавить типизацию (Typescript)
добавить статистику использования API
добавить статистику использования бота
расширить отслеживание ссылок и событий в Google Analytics
заменить Google Analytics на что-то полегче и более соответствующее GDPR.
Велкам в комментарии!
md_backend_binance
Добрый день
1) Где у вас хранятся допустим список кофеин? Выдает список просто отсортированный по имени или близкие относительно твоего положения ? Как происходит пагинация?
2) В этом API (или библиотеке) можно сделать тоже самое , но только не от бота , а от своего аккаунта (тоесть с твоим логином паролем который будет слушать команды в каком то чате)
mvs Автор
Добрый)
1) Список берётся из внешнего сервиса по REST API, в нём хранится в БД. Подробности в первой статье.
По команде /list выводятся все кофейни без пагинации, по /nearest или просто отправке своего местоположения - одна ближайшая кофейня
2) Не знаю.