The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.

NAME

Bot::ChatBots::Telegram::Guide::Tutorial - Tutorial Guide

INTRODUCTION

Telegram is a messaging application that is more or less in the same market space of WhatsApp. Differently from WhatsApp, though, Telegram allows for easily creating bots which you can interact with.

Bot::ChatBots::Telegram is a specialization of Bot::ChatBots for bots meant to connect to Telegram.

In this guide, we will take a first look to using this module, following this sequence:

  • registering your bot in Telegram, which ends up getting a token

  • set up a simple bot that repeatedly calls the Telegram API for getting new activities in the participating chats. We'll make it respond to a couple of stimula to warm up a bit;

  • take a look at how your bot can be proactive instead of only responding to stimula coming from the chats;

  • evolve the bot as a web-service that is notified by Telegram of new activities, without the need for active polling.

TOKEN

You cannot create a bot without a Telegram account, so you have to create one. You will be able to figure out how, no doubts!

After this, you can create many bots for many different purposes. Each of them has an associated token, which is a code that identifies your bot in the API calls, as well as being used as an authentication code (sort of mixing a username and a password in a single string).

How do you get a token for a new bot, then? Simply put, now that you are registered you can chat with BotFather, which (not surprisingly) is another bot that will guide you through the bot registration process. During this, you will also have to assign a name to your bot, so that you will be later able to chat with it from a Telegram client.

A token will be something like this:

   nnnnnnnnn:ssssssssssssssssssssssss-ssssssssss

where n are digits and s are alphanumeric characters. In the following examples, we will assume that the token is available through the environment variable TOKEN, like this in bash:

   $ export TOKEN='nnnnnnnnn:ssssssssssssssssssssssss-ssssssssss'

COMPULSIVE, REACTIVE BOT

The simplest way to start is to build a bot with Bot::ChatBots::Telegram::LongPoll, like in the example below. LongPoll means that the program will enter an indefinite loop, continuously asking Telegram for updates (i.e. new messages of interest for the bot).

The generic interaction model for longpoll is the following:

 .                            ..Bot Application.....
                              :                    :
   __________________         :     ____________   :
  /                  \        :    /            \  :
  |                  |<----------1-|            |  :
  |  Telegram Server |        :    |  LongPoll  |  :
  |                  |-2---------->|            |  :
  \__________________/        :    \____________/  :
              |  ^            :        |           :
              5  |            :        3           :
              |  |            :        |           :
              |  |            :     ___v______     :
              |  |            :    /          \    :
              |  +---------------4-|          |    :
              |               :    |  Sender  |    :
              +------------------->|          |    :
                              :    \__________/    :
                              :                    :
                              :....................:

   1, 2: Poll for new Update(s)
   3   : internal call
   4, 5: Telegram API Request/Response

Your application polls for updates; as a reaction to them, it can decide to send a message back to the server, using a Bot::ChatBots::Telegram::Sender object that wraps all the machinery for the API code.

The Program

Save this in a file:

   #!/usr/bin/env perl
   use strict;
   use warnings;
   use Bot::ChatBots::Telegram::LongPoll;

   Bot::ChatBots::Telegram::LongPoll->new(
      token     => $ENV{TOKEN},
      processor => \&process_record,
      start     => 1,
   );

   sub process_record {
      my $record = shift;

      my $type    = $record->{data_type};
      my $payload = $record->{payload};
      if ($type eq 'Message' && exists($payload->{from}) ) {
         my $text      = $payload->{text} || '';
         my $peer_name = $payload->{from}{first_name} || 'U. N. Known';
         print {*STDERR} "$peer_name says: $text\n";
         if ($text eq '/start') {
            $record->{send_response} = 'Very simple... just send /hello';
         }
         elsif ($text eq '/hello') {
            $record->{send_response} = "Hello to you, $peer_name";
         }
      }

      return $record; # follow on..
   }

A few comments:

  • to get started, our Bot::ChatBots::Telegram::LongPoll needs a few configurations, including the token to identify/authenticate as a bot in Telegram, a processor sub reference that will be called for each incoming Update from Telegram, and the indication to start right away (i.e. immediately enter the indefinite loop for polling new Updates).

  • the process_record sub does all the work when a new message is pulled from Telegram. When you receive a Message in the Update, you will find it inside $record->{payload}; inside that you might find the text that was sent, who sent it, etc. etc.

  • you don't necessarily have to send something back. You can do this easily setting key send_response in $record anyway, just set the message you want to appear in the chat and you're done!

  • pass $record as the outcome of your function.

Example Session

Now we can run our program (remember to set the TOKEN environment variable!):

   $ export TOKEN='...'
   $ perl tutorial-longpoll-01

If it blocks... it's a good sign. Now head to your Telegram client, look for your bot (it should be right there immediately after BotFather created it) and type /start (or press the Start button, if any appears). You will see something like this (with due change of names):

   You   : /start
   TheBot: Very simple... just send /hello

Now type /hello as hinted:

   You   : /hello
   TheBot: Hello to you, You

You are online, yay!

COMPULSIVE, PROACTIVE BOT

Your bot might want to say something from time to time. It might be a joke. It might be that you asked it to remind you to make a call at a given time. Or it might just be trying to warn you about what a splendid day you're missing outside!

The first thing to take into consideration is that Bot::ChatBots::Telegram::LongPoll relies on Mojo::IOLoop for handling the undefinitely long loop where it does the polling from time to time. Hence, whatever you can fit inside a Mojo::IOLoop will be good!

The second thing you have to take into account is... how do you communicate to Telegram outside of an update that came from there? For this, you need a Bot::ChatBots::Telegram::Sender, which you can obtain a couple of ways, like in the examples below:

   use Bot::ChatBots::Telegram::Sender;
   my $sender = Bot::ChatBots::Telegram::Sender->new(token => $ENV{TOKEN});

   # $bcb is a Bot::ChatBots::Telegram::LongPoll object
   my $other_sender = $bcb->sender;

We will use the second approach in the following, although the first one is perfectly valid (and it might come handy e.g. inside a Minion). Now we are ready to expand your example bot.

The Program

   #!/usr/bin/env perl
   use strict;
   use warnings;
   use Mojo::IOLoop;
   use Bot::ChatBots::Telegram::LongPoll;

   my $bcb = Bot::ChatBots::Telegram::LongPoll->new(
      token     => $ENV{TOKEN},
      processor => \&process_record,
      start     => 0,   # DO NOT START RIGHT AWAY!
   );

   setup_recurring();

   # now it's time to hand operations over to Mojo::IOLoop
   $bcb->start;

   # track nagging...
   {
      my %nagged;
      sub setup_recurring {
         Mojo::IOLoop->recurring(
            10 => sub {
               my $sender = $bcb->sender;
               for my $chat_id (keys %nagged) {
                  $sender->send_message(
                     {
                        text => 'whooops!',
                        chat_id => $chat_id,
                     }
                  );
               }
            }
         );
      }
      sub nag_on  { $nagged{$_[0]} = 1 }
      sub nag_off { delete $nagged{$_[0]} }
   }

   sub process_record {
      my $record = shift;

      my $type    = $record->{data_type};
      my $payload = $record->{payload};
      if ($type eq 'Message' && exists($payload->{from}) ) {
         my $text      = $payload->{text} || '';
         my $peer_name = $payload->{from}{first_name} || 'U. N. Known';
         my $chat_id   = $record->{channel}{id};
         print {*STDERR} "$peer_name says: $text\n";
         if (($text eq '/start') || ($text eq '/help')) {
            $record->{send_response} = <<'END';
   Very simple:
   * for help type /help
   * for greeting type /hello
   * for being annoyed every 10 s type /nag on
   * to stop annoyance type /nag off
   * to be reminded type /remind <seconds> <message>
   END
         }
         elsif ($text eq '/hello') {
            $record->{send_response} = "Hello to you, $peer_name";
         }
         elsif ($text eq '/nag on') {
            nag_on($chat_id);
            $record->{send_response} = 'OK, to deactivate /nag off';
         }
         elsif ($text eq '/nag off') {
            nag_off($chat_id);
            $record->{send_response} = 'OK, to reactivate /nag on';
         }
         elsif (my (          $delay,        $msg) = $text =~ m{
               \A /remind \s+ ([1-9]\d*) \s+ (.*)
            }mxs)
         {
            Mojo::IOLoop->timer($delay => sub {
               $bcb->sender->send_message(
                  {
                     text => "$peer_name: remember $msg",
                     chat_id => $chat_id,
                  }
               );
            });
            $record->{send_response} = "I'll try my best!";
         }
      }

      return $record; # follow on..
   }

The program is a bit more complicated than before, but it does so much more! Again, some notes:

  • we are setting start to 0 when creating the object, instead of 1 as before. This will allow us to avoid starting the loop right on the spot, define additional things (in our case, encapsulated inside setup_recurring) and then $bcb->start the loop.

  • As anticipated, Mojo::IOLoop is the real workhorse behind this, so we can take advantage of its capabilities. Before starting, we set up a recurrent job that will send a nagging message every 10 seconds to all channels that ask for it (tracked through variable %nagged).

  • The process_record didn't change shape, just got a bit longer. The help evolved to explain all the new commands, some of which are quite simple (e.g. the nagging handling ones just set or reset a flag in %nagged), other again take advantage of Mojo::IOLoop to do something (like the new command /remind).

  • In both callbacks passed to Mojo::IOLoop we use a Bot::ChatBots::Telegram::Sender object, relying upon the same object that $bcb uses to get new updates. As already anticipated, nothing

  • In all cases we send a quick feedback to the user, relying upon send_message as before. This will use $brb's internal Bot::ChatBots::Telegram::Sender instance to invoke the Telegram API; stops you from creating another object using the same token.

Example Session

Start the new bot and try it:

   You   : /help
   TheBot: Very simple:
           * for help type /help
           * for greeting type /hello
           * for being annoyed every 10 s type /nag on
           * to stop annoyance type /nag off
           * to be reminded type /remind <seconds> <message>

Let's start some nagging, wait about 40 second before going on with other commands:

   You   : /nag on
   TheBot: OK, to deactivate /nag off
   (some time after)
   TheBot: whooops!
   (10 s after)
   TheBot: whooops!
   (10 s after)
   TheBot: whooops!

You can send other commands in the meantime:

   TheBot: whooops!
   (10 s after)
   TheBot: whooops!
   You   : /hello
   TheBot: Hello to you, You
   (some time after)
   TheBot: whooops!
   (10 s after)
   TheBot: whooops!

Enough:

   TheBot: whooops!
   You   : /nag off
   TheBot: OK, to reactivate /nag on

Have to do something in 30 seconds?

   You   : /remind 30 do that thing!
   TheBot: I'll try my best!
   (30 seconds after)
   TheBot: You: remember do that thing!

RELAXED, PROACTIVE BOT

Having your bot repeatedly asking for updates is not very appealing, and probably also not very scalable as well. For this reason, you can ask Telegram to notify you when new Updates are available, by providing a callback URL called web hook.

Using web hooks is only marginally more complicated, mostly consisting of a different configuration setup. But... there are a few twists that will have to be discussed before diving into the code.

This is the generic model for a web hook is the following:

 .                            ..Bot Application.....
                              :                    :
   __________________         :     ____________   :
  /                  \        :    /            \  :
  |                  |-1---------->|            |  :
  |  Telegram Server |        :    |  WebHook   |  :
  |                  |<----------2-|            |  :
  \__________________/        :    \____________/  :
              |  ^            :        |           :
              5  |            :        3           :
              |  |            :        |           :
              |  |            :     ___v______     :
              |  |            :    /          \    :
              |  +---------------4-|          |    :
              |               :    |  Sender  |    :
              +------------------->|          |    :
                              :    \__________/    :
                              :                    :
                              :....................:

   1, 2: WebHook Request/Response
   3   : internal call
   4, 5: Telegram API Request/Response

As you can see, it's mostly the same as the longpoll one, except that in this case the initial message 1 is sent from Telegram. Additionally, as we already discovered, it's possible to use the sender outside of an update.

WebHooks Are Fussy

One thing is to decide when to get Updates using a client, possibly behind a proxy; another thing is to set up a service that acts as the contact point for Telegram. The road to be a server that Telegram is fine about has a few milestones, as you will see in the following subsections.

Telegram will need a URL to contact you:

   $ export BOT_URL="$BOT_PROTO://$BOT_ADDRESS:$BOT_PORT$BOT_PATH"

Before you can export this... you have to decide a few things!

Find a public spot and set BOT_ADDRESS

While your program will still act as a client to Telegram, it now also becomes a server. This means finding out a suitable, public spot on the internet; it might be your home computer with a public IP address, a VPS, or a free-tier AWS virtual machine if you're lucky to have some capacity.

You might even go the extra mile and set up a DNS resolution to transform a domain name into that IP address; Telegram will not insist on this anyway.

In the following, we will assume that you know your (public) IP Address:

   $ export BOT_ADDRESS='...'

Decide a port and set BOT_PORT

Not every port is born equal, because Telegram will contact you only on one of the following ones: 443, 80, 88 or 8443.

Again, we will fit this piece of configuration into an environment variable:

   $ export BOT_PORT="...'

No decision on BOT_PROTO='https'

Telegram will only work with encryption, so you will have to set up TLS. This is not difficult to do with Mojolicious, but you still have to set up certificates. So, there is actually nothing to decide about BOT_PROTO:

   $ export BOT_PROTO='https'

The best would be to obtain a publicly recognised certificate. You can reuse something that you already have, buy something, or take a look at "Let's Encrypt" to get one free.

Telegram will not insist on this, anyway, and it is also possible to generate a self-signed certificate and then hand it over to Telegram. On the other hand, it will insist on the certificate and the domain or IP address of your endpoint to match, so you will have to generate your certificate depending on it, e.g. using the following command:

   $ openssl req -x509  -nodes \
      -newkey rsa:2048 \
      -sha256 \
      -days 365 \
      -subj "/C=IT/ST=Roma/L=Roma/O=Pinco Pals/CN=$BOT_IP" \
      -keyout server.key \
      -out server.crt

Hence, server.key and server.crt will be hanging around in the directory you are using, keep this in mind!

You can fiddle with the -subj part of course, as long as you make sure that the CN part matches the IP address or domain name you chose for your bot endpoint.

Decide a path and set BOT_PATH

This is really up to you. If you are expanding a previous program, using a reverse proxy, or just want to go deeper than the root path, you can just do so:

   $ export BOT_PATH='...'

You can also decide to leave this part empty.

Wrap it all together

I find it useful to put all environment variables in a single configuration file that can be sourced in a shell:

   # we will ignore BOT_PROTO and just use https
   export BOT_ADDRESS='...'   # Public IP or public domain address
   export BOT_PORT='...'      # 80, 88, 443, 8443
   export BOT_PATH='/'        # or leave it empty
   export BOT_URL="https://$BOT_ADDRESS:$BOT_PORT$BOT_PATH"

The Program

Our program will be a full-fledged Mojolicious application this time, although this does not mean it will be much more complicated. After the previous section, we know there are two additional files in the directory, namely the certificate file server.crt and the private key file server.key.

   #!/usr/bin/env perl

   use strict;
   use warnings;
   use Mojolicious::Lite;

   my $token   = $ENV{TOKEN};
   my $bot_url = $ENV{BOT_URL};
   my $certificate = do { local (@ARGV, $/) = 'server.crt'; <> };

   plugin 'Bot::ChatBots::Telegram' => instances => [
      [
         'WebHook',
         processor   => \&process_record,
         register    => 1,
         token       => $token,
         unregister  => 1,
         url         => $bot_url,
         certificate => $certificate,
      ],
   ];

   # set this as a "shim" to make the whole thing similar to LongPoll
   my $bcb = app->chatbots->telegram->instances->[0];

   # encapsulating initialization comes handy
   setup_recurring();

   # now it's time to hand operations over to Mojolicious
   app->start;

   ### EVERYTHING IS UNCHANGED BELOW THIS LINE ############################
   # track nagging...
   {
      my %nagged;
      sub setup_recurring {
         Mojo::IOLoop->recurring(
            10 => sub {
               my $sender = $bcb->sender;
               for my $chat_id (keys %nagged) {
                  $sender->send_message(
                     {
                        text => 'whooops!',
                        chat_id => $chat_id,
                     }
                  );
               }
            }
         );
      }
      sub nag_on  { $nagged{$_[0]} = 1 }
      sub nag_off { delete $nagged{$_[0]} }
   }

   sub process_record {
      my $record = shift;

      my $type    = $record->{data_type};
      my $payload = $record->{payload};
      if ($type eq 'Message' && exists($payload->{from}) ) {
         my $text      = $payload->{text} || '';
         my $peer_name = $payload->{from}{first_name} || 'U. N. Known';
         my $chat_id   = $record->{channel}{id};
         print {*STDERR} "$peer_name says: $text\n";
         if (($text eq '/start') || ($text eq '/help')) {
            $record->{send_response} = <<'END';
   Very simple:
   * for help type /help
   * for greeting type /hello
   * for being annoyed every 10 s type /nag on
   * to stop annoyance type /nag off
   * to be reminded type /remind <seconds> <message>
   END
         }
         elsif ($text eq '/hello') {
            $record->{send_response} = "Hello to you, $peer_name";
         }
         elsif ($text eq '/nag on') {
            nag_on($chat_id);
            $record->{send_response} = 'OK, to deactivate /nag off';
         }
         elsif ($text eq '/nag off') {
            nag_off($chat_id);
            $record->{send_response} = 'OK, to reactivate /nag on';
         }
         elsif (my (          $delay,        $msg) = $text =~ m{
               \A /remind \s+ ([1-9]\d*) \s+ (.*)
            }mxs)
         {
            Mojo::IOLoop->timer($delay => sub {
               $bcb->sender->send_message(
                  {
                     text => "$peer_name: remember $msg",
                     chat_id => $chat_id,
                  }
               );
            });
            $record->{send_response} = "I'll try my best!";
         }
      }

      return $record; # follow on..
   }

As you will notice, only the first part changed with respect to the proactive version of the longpoll bot (there is a comment line indicating where differences end). This is the new part:

   #!/usr/bin/env perl

   use strict;
   use warnings;
   use Mojolicious::Lite;

   my $token   = $ENV{TOKEN};
   my $bot_url = $ENV{BOT_URL};
   my $certificate = do { local (@ARGV, $/) = 'server.crt'; <> };

   plugin 'Bot::ChatBots::Telegram' => instances => [
      [
         'WebHook',
         processor   => \&process_record,
         register    => 1,
         token       => $token,
         unregister  => 1,
         url         => $bot_url,
         certificate => $certificate,
      ],
   ];

   # set this as a "shim" to make the whole thing similar to LongPoll
   my $bcb = app->chatbots->telegram->instances->[0];

   # encapsulating initialization comes handy
   setup_recurring();

   # now it's time to hand operations over to Mojolicious
   app->start;

A few comments:

  • as anticipated, it will be a full-fledged Mojolicious application, but it needs not be a complicated one. Mojolicious::Lite will do fine;

  • in addition to TOKEN we now have to be aware of environment variable BOT_URL and load the TLS certificate file server.crt, so that it can be communicated to Telegram (this is needed only for self-signed certificates);

  • not surprisingly, we are using Bot::ChatBots::Telegram::WebHook instead of Bot::ChatBots::Telegram::LongPoll. It is a Mojolicious plugin, so we load it as such with the interface shown in the example;

  • our code from the longpoll days was relying upon a variable $bcb to get access to a Bot::ChatBots::Telegram::Sender object. Again, this is not really necessary, because you only need TOKEN to get yours, but we are defining this variable anyway to show the ease of transition from the longpoll version to the webhook;

  • the call to setup_recurring() is the same as before, which gives you why it's handy to encapsulate these configurations in one place;

  • last, we start the application instead of the poller.

Example Session

Save the program as tutorial-webhook and start as:

   $ perl tutorial-webhook daemon \
       -l "https://*:$BOT_PORT?cert=server.crt&key=server.key"

Mojolicious will work out of the box with the certificate and key you generate, but you have to tell it about them!

At this point, just repeat the session you did for the long poll... you should notice no difference, because we reused all of its business logic!

A Note On unregister

Telegram rules require you to unregister your bot URL if you want to restart using the long polling interface. For this reason, when you set up the plugin you can pass the option:

   unregister => 1

and let the bot do it for you automatically when exiting the process.

Beware though that this can bite you badly if you are automating operations in some kind of Platform-as-a-Service environment. When upgrading a service (e.g. because you pushed some new feature), they might start up the new code and only then tear down the old one; hence, if you leave unregister set, the old instance is likely to kill your service, because it will unregister the URL that your new instance communicated to Telegram.

For this reason... it's probably better to leave unregister unset.

SEE ALSO

The example bots described in this document can be found in the eg directory of the distribution, named tutorial-longpoll-01, tutorial-longpoll-02 and tutorial-webhook respectively.

Bot::ChatBots, Bot::ChatBots::Telegram.

AUTHOR

Flavio Poletti <polettix@cpan.org>

COPYRIGHT AND LICENSE

Copyright (C) 2018 by Flavio Poletti <polettix@cpan.org>

This module is free software. You can redistribute it and/or modify it under the terms of the Artistic License 2.0.

This program is distributed in the hope that it will be useful, but without any warranty; without even the implied warranty of merchantability or fitness for a particular purpose.