Спешлти-кофеен на Кипре стало ещё больше
Спешлти-кофеен на Кипре стало ещё больше

Финальная часть разработки простого проекта про specialty-кофейни на Кипре. В первой части я рассказал про API микросервис, во второй - про фронтэнд-сайт и теперь - про телеграм-бота.

Изначально перед ботом ставились простые задачи:

  1. /map - карта кофеен

  2. /list - список кофеен

  3. подробности о кофейне

  4. /random - случайная кофейня

  5. поиск кофейни по названию

  6. поиск ближайшей кофейни по своему местоположению или по команде /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-образ, конечно, стал меньше, но пришлось использовать отдельный роутер:

  1. Для пропуска только POST-запросов к обработчикам бота

  2. Для редиректа dev-домена вида .fly.dev на основной домен

  3. Раздачи статики (robots.txt, favicon.ico и т.д.)

  4. Блокировки всех остальных запросов

Итоговый роутер и 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.

Велкам в комментарии!

Комментарии (2)


  1. md_backend_binance
    23.07.2022 23:01
    +1

    Добрый день

    1) Где у вас хранятся допустим список кофеин? Выдает список просто отсортированный по имени или близкие относительно твоего положения ? Как происходит пагинация?

    2) В этом API (или библиотеке) можно сделать тоже самое , но только не от бота , а от своего аккаунта (тоесть с твоим логином паролем который будет слушать команды в каком то чате)


    1. mvs Автор
      23.07.2022 23:06

      Добрый)

      1) Список берётся из внешнего сервиса по REST API, в нём хранится в БД. Подробности в первой статье.

      По команде /list выводятся все кофейни без пагинации, по /nearest или просто отправке своего местоположения - одна ближайшая кофейня

      2) Не знаю.