Pragmatic development 3: Telegram bot
Tags: telegram, chatbot, php, tutorial
Published: 2022-10-14 13:17:22

The conclusion of a simple project about specialty coffee shops in Cyprus. The [first part](https://dev.to/vladimir_mvs/pragmatic-development-2lph) focused on the API microservice, the [second](https://dev.to/vladimir_mvs/pragmatic-development-2-frontend-1m79) 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](https://github.com/specialtycoffeecyprus) 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](https://dev.to/vladimir_mvs/pragmatic-development-2lph). The final [composer.json](https://github.com/specialtycoffeecyprus/bot/blob/develop/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](https://github.com/specialtycoffeecyprus/bot/blob/develop/public/index.php) to be condensed into only 32 lines!
```php
$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();
```

A [/nearest command](https://github.com/specialtycoffeecyprus/bot/blob/develop/app/Handlers/Commands/NearestCommand.php) example:
```php
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)),
        ]);
    }
}
```

[Location handler](https://github.com/specialtycoffeecyprus/bot/blob/develop/app/Handlers/LocationHandler.php) example:
```php
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;
    }
}
```

The same short and concise [middleware](https://github.com/specialtycoffeecyprus/bot/tree/develop/app/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](https://github.com/specialtycoffeecyprus/bot/tree/develop/tests) use the mocked [ApiClient](https://github.com/specialtycoffeecyprus/bot/blob/develop/tests/ApiClient.php), which returns predefined responses an infinite number of times.

## Monitoring

The same [Sentry](https://docs.sentry.io/platforms/php/) 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](http://fly.io/) platform, but now with 300ms-load-time [Machines](https://fly.io/docs/reference/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](https://github.com/specialtycoffeecyprus/specialty-bot/blob/develop/app/router.php) and [Dockerfile](https://github.com/specialtycoffeecyprus/specialty-bot/blob/develop/Dockerfile) (same layered as in API part).

## CI/CD

[Github Action](https://github.com/specialtycoffeecyprus/specialty-bot/blob/develop/.github/workflows/deploy.yml) 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](https://github.com/specialtycoffeecyprus/specialty-bot/), 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.