Pragmatic development 3: Telegram bot

vladimir_mvs

Vladimir

Posted on October 14, 2022

Pragmatic development 3: Telegram bot

The conclusion of a simple project about specialty coffee shops in Cyprus. The first part focused on the API microservice, the second on the frontend site, and the third on the Telegram bot.

The bot began with simple tasks:

  • /map - display map of coffee shops
  • /list - show list of coffee shops
  • show coffee shop details
  • /random - show random coffee shop
  • search for a coffee shop by the name
  • search for the nearest coffee shop by location or by /nearest command

During the implementation, it was discovered that Telegram cannot display an embedded map with multiple markers, nor does it send the location in the web version. As a result, instead of a real map, I had to show a link to a website with a map and a stub message instead of a response on an empty location. Everything else has been done.

The project code is open, bot https://t.me/SpecialtyCoffeeCyBot.

Architecture

Nutgram was chosen as the bot's base after much deliberation: it is the most lightweight, simple, and modern library. A fully configured DI container comes as a bonus, allowing you to avoid manual service initialization and delivery to customers.

Using an actual version of PHP 8.1 allowed me to write slightly less code while achieving slightly better performance. Development is made much easier by promoted properties, read-only properties, and strict typing.

The composer settings are simple as the API. The final composer.json file.

Telegram updates are received at the webhook endpoint and transmitted to handlers of commands and message types. Handlers can respond on their own or request data from the REST API. For the unexpected, there are Fallback, Exception, and ApiError handlers.

The use of short, single-action invokable handlers allowed the bot's logic to be condensed into only 32 lines!

$bot = new Nutgram($_ENV['BOT_TOKEN'], [
    'timeout' => $_ENV['CONNECT_TIMEOUT'],
    'logger' => ConsoleLogger::class
]);
$bot->setRunningMode(Webhook::class);

$bot->middleware(AuthMiddleware::class);

$bot->fallback(FallbackHandler::class);
$bot->onException(ExceptionHandler::class);
$bot->onApiError(ApiErrorHandler::class);

$bot->onText(NearestCommand::SEND_TEXT, NotSupportedHandler::class);

$bot->onMessageType(MessageTypes::TEXT, SearchHandler::class)->middleware(SearchRequirementsMiddleware::class);
$bot->onMessageType(MessageTypes::LOCATION, LocationHandler::class);

$bot->onMessageType(MessageTypes::NEW_CHAT_MEMBERS, NullHandler::class);
$bot->onMessageType(MessageTypes::LEFT_CHAT_MEMBER, NullHandler::class);

$bot->onCommand(ListCommand::getName(), ListCommand::class)->description(ListCommand::getDescription());
$bot->onCommand(MapCommand::getName(), MapCommand::class)->description(MapCommand::getDescription());
$bot->onCommand(NearestCommand::getName(), NearestCommand::class)->description(NearestCommand::getDescription());
$bot->onCommand(RandomCommand::getName(), RandomCommand::class)->description(RandomCommand::getDescription());
$bot->onCommand(StartCommand::getName(), StartCommand::class)->description(StartCommand::getDescription());

$bot->registerMyCommands();

$http = new Client(['base_uri' => $_ENV['API_URL']]);
$bot->getContainer()->addShared(Client::class, $http);

$bot->run();
Enter fullscreen mode Exit fullscreen mode

A /nearest command example:

final class NearestCommand extends BaseCommand
{
    public const SEND_TEXT = 'Send location';


    public static function getName(): string
    {
        return 'nearest';
    }


    public static function getDescription(): string
    {
        return 'Show nearest specialty coffee shop';
    }


    public function getAnswer(): Answer
    {
        return new TextAnswer('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)),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Location handler example:

final class LocationHandler extends BaseHandler
{
    private Location $location;


    public function __construct(Sender $sender, private readonly ApiService $api)
    {
        parent::__construct($sender);
    }


    public function __invoke(Nutgram $bot): ?Message
    {
        $this->location = $bot->message()->location;

        return $this->sender->send($this->getAnswer());
    }


    /** @inheritDoc */
    public function getAnswer(): Answer|array
    {
        $cafe = $this->api->getNearest((string)$this->location->latitude, (string)$this->location->longitude);

        return [
            new TextAnswer(Formatter::item($cafe), ['parse_mode' => ParseMode::HTML]),

            new VenueAnswer((float)$cafe->latitude, (float)$cafe->longitude, $cafe->name, '', [
                'google_place_id' => $cafe->placeId,
                'reply_to_message_id' => 0,
                'reply_markup' => ['remove_keyboard' => true],
            ]),
        ];
    }


    public function getLocation(): Location
    {
        return $this->location;
    }


    public function setLocation(Location $location): LocationHandler
    {
        $this->location = $location;

        return $this;
    }
}
Enter fullscreen mode Exit fullscreen mode

The same short and concise middleware is used to validate search data as well as check the legitimacy of messages.

Configuration

Common options and secret names are stored in the .env file, while local overrides are stored in the .env.local file.

Tests

I'm pleased that the Nutgram library provides enough possibilities for writing tests. Unlike simple Guzzle mocks, project's tests use the mocked ApiClient, which returns predefined responses an infinite number of times.

Monitoring

The same Sentry as in the API microservice and the frontend, in the .env just specify an empty value of SENTRY_DSN (for clarity), and write the actual value to secret.

Deployment

Still the same Fly.io platform, but now with 300ms-load-time Machines. Generally, it's FaaS (serverless), but in my case, with the PHP server, it's still a regular VM.

I used an embedded PHP server instead of the usual combination of PHP-FPM + Nginx/Caddy + Supervisor to speed up the project's launch. The Docker image of course got smaller, but I had to use a separate router:

  • pass only POST requests to bot handlers
  • redirect the dev domain like .fly.dev to the main domain
  • distribute statics (robots.txt, favicon.ico, etc.)
  • block all other requests

Final router and Dockerfile (same layered as in API part).

CI/CD

Github Action is simple enough: update the machine flyctl deploy and update the webhook registration curl -sS ${{ secrets.APP_URL }}/setup.php.

All secrets are stored on the hosting platform and partially duplicated in the GitHub production environment for webhook registration.

At this stage, the bot is live, hosted in a production and available to all users. The project is fully completed :-)

Bot repository, website https://specialtycoffee.cy/

TODO

The final non-critical tasks for the entire project:

  • health checks for real service response, not just port 'survivability'.
  • optimize the build with Caddy
  • try a Buildpack or Nixpack
  • replace the built-in PHP server with something more secure
  • add strict typing (Typescript)
  • add API usage stats
  • add bot usage statistics
  • expand link and event tracking in Google Analytics
  • replace Google Analytics with something lighter and more GDPR compliant.
💖 💪 🙅 🚩
vladimir_mvs
Vladimir

Posted on October 14, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Pragmatic development 3: Telegram bot
telegram Pragmatic development 3: Telegram bot

October 14, 2022