Skip to content
This repository was archived by the owner on Nov 10, 2022. It is now read-only.

Files

Latest commit

33f363c · May 14, 2022

History

History
838 lines (662 loc) · 28.3 KB

WORKSHOP.adoc

File metadata and controls

838 lines (662 loc) · 28.3 KB

Java & Telegram ChatBot Workshop

Copyright (c) 2018-2022 Marcus Fihlon

Prerequesites

  • Java 17

  • Java IDE

  • You should be familiar with your IDE

  • You need your own Telegram account

  • You need Telegram installed on your mobile or desktop

Register

Use Telegram to talk to the BotFather to register a new bot. Write down your bot username and your bot token.

Project setup

For this workshop we use gradle. Please create a new gradle project using your IDE. I highly recommend to use the gradle application plugin to easily start you bot from the command line:

apply plugin: 'application'
mainClassName = 'ch.fihlon.workshop.chatbot.WorkshopBot'

Of course, you may have to modify the mainClassName according your package structure and bot name. To run your bot (while you follow this workshop), just run the following command on your commandline:

./gradlew run

On Windows, you may have to replace / by \.

Getting started

Dependencies

To use the Telegram Bot API, please add the following dependencies:

implementation group: 'org.telegram', name: 'telegrambots', version: '6.0.1'
implementation group: 'org.slf4j', name: 'slf4j-api', version: '1.7.36'
implementation group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.36'

Create a bot class

Create a class GettingStartedBot that extends TelegramLongPollingBot and add empty methods to satisfy the class contract:

public class GettingStartedBot extends TelegramLongPollingBot {

    @Override
    public String getBotUsername() {
        return null;
    }

    @Override
    public String getBotToken() {
        return null;
    }

    @Override
    public void onUpdateReceived(final Update update) {
    }

}

Bot username

The first method must return the username of your bot you wrote down while you registered your bot:

@Override
public String getBotUsername() {
    return "MyWorkshopBot";
}

Bot token

As you might imagine, the second method has to return the token of your bot. The token is needed for identification, like a password, so keep it private and do not commit it to any registry:

@Override
public String getBotToken() {
    return "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ";
}

React on a message

In the third method you write your code which will be executed when your bot receives a message. For our first example we will just send the same message back to the sender:

@Override
public void onUpdateReceived(final Update update) {
    if (update.hasMessage() && update.getMessage().hasText()) {
        final var message = update.getMessage();
        final var chatId = message.getChatId();
        final var text = message.getText();
        final var answer = new SendMessage();
        answer.setChatId(String.valueOf(chatId)); // Why String? Because it can be a username, too.
        answer.setText(text);
        try {
            execute(answer);
        } catch (final TelegramApiException e) {
            e.printStackTrace();
        }
    }
}

This example should be quite easy to understand. First, we check if we have a message and it is a text message. Then we extract two values:

  1. The chatId, which identifies a chat. We need this id, to send the answer to the correct person. Every chat has a unique id. We could save this id for later use and reply at any time. But here, we reply immediately.

  2. The text. This is just the text of the message, the bot received.

To send an answer, we have to create a SendMessage method and set the chatId and the text, which should be send. Then we execute this method, which will send the message. Of course, there can happen a lot of errors (servers down, network failures etc), so have to do really good error handling like in the example above.

Start your bot

For our simple example, we just a good old main method. The start needs four steps:

  1. We need an instance of the Telegram API

  2. We need an instance of our bot

  3. We need to register our bot instance at Telegram

public static void main(final String[] args) throws TelegramApiException {
    final var api = new TelegramBotsApi(DefaultBotSession.class);   // 1
    final var bot = new GettingStartedBot();                        // 2
    api.registerBot(bot);                                           // 3
}

Play with your bot

Now, start your bot by running your main method.

Congratulations!

Thinking in Abilities

Dependencies

To use the Telegram Bot Ability API, please add the following dependencies:

implementation group: 'org.telegram', name: 'telegrambots-abilities', version: '6.0.1'
implementation group: 'org.slf4j', name: 'slf4j-api', version: '1.7.36'
implementation group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.36'

Create a bot class

Create a class WorkshopBot that extends AbilityBot and add a no argument constructor and empty methods to satisfy the class contract:

public class WorkshopBot extends AbilityBot {

    WorkshopBot() {
        super(null, null);
    }

    @Override
    public long creatorId() {
        return 0;
    }

}

Bot token and username

The easy part: Add the token and username of your bot as constants to your class and specify them in the super constructor call:

public class WorkshopBot extends AbilityBot {

    private static String BOT_TOKEN = "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    private static String BOT_USERNAME = "MyWorkshopBot";

    WorkshopBot() {
    super(BOT_TOKEN, BOT_USERNAME);
    }

    …
}

Your Telegram ID

AbilityBot forces a single implementation of creator ID. This ID corresponds to you, the bot developer. The bot needs to know its master since it has sensitive commands that only the master can use. So, if your Telegram ID Is 1234567890, then add the following method:

private static long CREATOR_ID = 1234567890L;

@Override
public int creatorId() {
    return CREATOR_ID;
}

If you do not know your Telegram ID, just start a chat to the userinfobot.

Say hello

Should be easy: Let’s say hello. For creating an ability, we use the builder pattern:

@SuppressWarnings({"unused", "WeakerAccess"})
public Ability sayHelloWorld() {
    return Ability
        .builder()
        .name("hello")                                                    // 1
        .info("says hello world")                                         // 2
        .locality(ALL)                                                    // 3
        .privacy(PUBLIC)                                                  // 4
        .action(context -> silent.send("Hello world!", context.chatId())) // 5
        .build();
}
  1. the name of the command

  2. a description of the command

  3. the location of the command (ALL, USER, GROUP)

  4. the privacy setting (PUBLIC, GROUP_ADMIN, ADMIN, CREATOR)

  5. the action to be executed

Start your bot

To start the ability bot we need to do exactly the same as with the bot, we created before:

  1. We need an instance of the Telegram API

  2. We need an instance of our bot

  3. We need to register our bot instance at Telegram

public static void main(final String[] args) throws TelegramApiException {
    final TelegramBotsApi api = new TelegramBotsApi(DefaultBotSession.class);   // 1
    final WorkshopBot bot = new WorkshopBot();                                  // 2
    api.registerBot(bot);                                                       // 3
}

Play with your bot

Now, start your bot by running your main method and send the /hello command to your bot.

Congratulations!

Wait! Since you’ve implemented an ability bot, you get factory abilities as well. Try:

  • /commands – Prints all commands supported by the bot. This will essentially print hello - says hello world. Yes! This is the information we supplied to the ability. The bot prints the commands in the format accepted by BotFather. So, whenever you change, add or remove commands, you can simply send /commands to your bot and forward that message to BotFather.

  • /claim – Claims this bot

  • /backup – returns a backup of the bot database

  • /recover – recovers the database

  • /promote @username – promotes user to bot admin

  • /demote @username – demotes bot admin to user

  • /ban @username – bans the user from accessing your bot commands and features

  • /unban @username – lifts the ban from the user

Replies

A reply is AbilityBot’s swiss army knife. It comes in two variants and is able to handle all possible use cases.

Standalon Reply

Standalone replies do not need abilities. Let’s add one to our bot:

@SuppressWarnings({"unused", "WeakerAccess"})
public Reply replyToPhoto() {
    return Reply.of(
        (bot, update) -> silent.send("Nice pic!", getChatId(update)),
            Flag.PHOTO);
}

As you can see, you just provide a lambda function which consumes the update. In addition to the required lambda function, replies can have optional predicates. In our example we let the bot know, that we only want to reply to images. Take a look at the Flag enum.

Wow, that was easy! How easy would it be to implement a VoxxedDaysZurichBot, where you can send pictures which are automatically uploaded to a Google Drive (or similar) share? If you are a nerd and finish this workshop early, try to implement it…

Ability Reply and own Predicates

In exactly the same manner, you are able to attach replies to abilities. This way you can localize replies that relate to the same ability.

@SuppressWarnings({"unused", "WeakerAccess"})
public Ability sayHi() {
    return Ability
        .builder()
        .name("hi")
        .info("says hi")
        .locality(ALL)
        .privacy(PUBLIC)
        .action(context -> {
            final String firstName = context.user().getFirstName();
            silent.send("Hi, " + firstName, context.chatId());
        })
        .reply(
            (bot, update) -> silent.send("Wow, nice name!", update.getMessage().getChatId()),
            TEXT,
            update -> update.getMessage().getText().startsWith("/hi"),
            isMarcus()
        )
        .build();
}

private Predicate<Update> isMarcus() {
    return update -> update.getMessage().getFrom().getFirstName().equalsIgnoreCase("Marcus");
}

In this example you can see how easy it is to create and use your own predicates. Using predicates, you can implement all checks, so your logic keeps clean and can focus on action.

Database Handling

If you use the ability bot, you have an integrated database. To persist the data, a file with the name of your bot is created in the working directory (depending on your IDE, usually project root folder).

Persistent Counter

Let’s use it to implement a simple counter:

@SuppressWarnings({"unused", "WeakerAccess"})
public Ability counter() {
    return Ability.builder()
        .name("count")
        .info("increments a counter per user")
        .privacy(PUBLIC)
        .locality(ALL)
        .action(context -> {
            final Map<String, Integer> counterMap = db.getMap("COUNTERS");
            final long userId = context.user().getId();
            final Integer counter = counterMap.compute(
                    String.valueOf(userId), (id, count) -> count == null ? 1 : ++count);
            final String message = String.format("%s, your count is now %d!",
                    context.user().getUserName(), counter);
            silent.send(message, context.chatId());
        })
        .build();
}

As you can see, the interface to the database is just a simple map. Cool, we can now implement actions that need persistence.

Automatic Contacts

The ability bot automatically stores basic user information of every user, who contacted your bot. So we have some kind of an automatically contact list. We can access this list very easy:

@SuppressWarnings({"unused", "WeakerAccess"})
public Ability contacts() {
    return Ability.builder()
        .name("contacts")
        .info("lists all users who contacted this bot")
        .privacy(PUBLIC)
        .locality(ALL)
        .action(context -> {
            final Map<String, User> usersMap = db.getMap("USERS");
            final String users = usersMap.values().stream().map(User::getUserName).collect(joining(", "));
            final String message = "The following users already contacted me: " + users;
            silent.send(message, context.chatId());
        })
        .build();
}

Photos

Receiving Photos

The process of receiving a photo is not very intuitive. Maybe it will be improved in the future. Anyway, let’s try to get the photo out of the message and store it to the filesystem.

From Telegram we do not get the photo directly. Instead, we get a list of PhotoSize objects. A list? Yeas, the photo will be available in different sizes. If the sender sends a photo from his mobile device, it will be displayed in the chat history as a thumbnail. That’s why one photo will end up in a list of PhotoSize objects. In our case, we want the original photo in the original size, so we sort that list by size and take the biggest one.

@SuppressWarnings({"unused", "WeakerAccess"})
public Reply savePhoto() {
    return Reply.of(
        (bot, update) -> {
            final List<PhotoSize> photos = update.getMessage().getPhoto();
            final PhotoSize photoSize = photos.stream()
                    .max(Comparator.comparing(PhotoSize::getFileSize))
                    .orElse(null);
            if (photoSize != null) {
                // TODO download the photo
                silent.send("Yeah, I got it!", getChatId(update));
            } else {
                silent.send("Houston, we have a problem!", getChatId(update));
            }
        },
        Flag.PHOTO);
}

So far, so good. but there is still no photo, just a PhotoSize object. We have to actively download the photo in that size, that we need, to reduce network traffic and server load. But to download a photo, we first need to get the file path of the photo. Sometimes photos already have a file path, sometimes not – then we have to ask Telegram for it. This is how we do that:

private String getFilePath(final PhotoSize photo) {
    final var filePath = photo.getFilePath();
    if (filePath != null && !filePath.isBlank()) {
        return filePath;
    }
    final GetFile getFileMethod = new GetFile();
    getFileMethod.setFileId(photo.getFileId());
    try {
        final org.telegram.telegrambots.meta.api.objects.File file = execute(getFileMethod);
        return file.getFilePath();
    } catch (final TelegramApiException e) {
        e.printStackTrace();
    }
    return null;
}

Be careful to use the correct File object!

Using the file path we are now able to download the photo from Telegram. Luckily, this task is very easy:

private File downloadPhoto(final String filePath) {
    try {
        return downloadFile(filePath);
    } catch (final TelegramApiException e) {
        e.printStackTrace();
    }
    return null;
}

With these two helper methods we can now finish our savePhoto method:

@SuppressWarnings({"unused", "WeakerAccess"})
public Reply savePhoto() {
    return Reply.of(
        (bot, update) -> {
            final List<PhotoSize> photos = update.getMessage().getPhoto();
            final PhotoSize photoSize = photos.stream()
                    .max(Comparator.comparing(PhotoSize::getFileSize))
                    .orElse(null);
            if (photoSize != null) {
                final String filePath = getFilePath(photoSize);
                final File file = downloadPhoto(filePath);
                System.out.println("Temporary file: " + file);
                silent.send("Yeah, I got it!", getChatId(update));
            } else {
                silent.send("Houston, we have a problem!", getChatId(update));
            }
        },
        Flag.PHOTO);
}

Uff, done! Try it and send a photo to your bot! On the console you can see the temporary file on the bot host. Now you can easily continue and move it everywhere you like or implement some filter magic and send the photo back to the user.

Sending Photos

Compared to receiving a photo it is very easy to send a photo. There are three ways to do send a photo and all the three ways have the following four steps in common:

  1. Create send method

  2. Set destination chat id

  3. Set the photo

  4. Send the photo

Send Photo from URL

In this example we implement a /logo command which will, difficult to guess, send a logo:

@SuppressWarnings({"unused", "WeakerAccess"})
public Ability sendLogo() {
    return Ability
        .builder()
        .name("logo")
        .info("send the logo")
        .locality(ALL)
        .privacy(PUBLIC)
        .action(context -> sendPhotoFromUrl("https://avatars3.githubusercontent.com/u/13538066?s=200&v=5", context.chatId()))
        .build();
}

private void sendPhotoFromUrl(final String url, final Long chatId) {
    final SendPhoto sendPhotoRequest = new SendPhoto(); // 1
    sendPhotoRequest.setChatId(String.valueOf(chatId)); // 2
    sendPhotoRequest.setPhoto(new InputFile(url));      // 3
    try {
        execute(sendPhotoRequest);                      // 4
    } catch (final TelegramApiException e) {
        e.printStackTrace();
    }
}

Send Photo from File ID

This is especially useful, if your bot receives a photo and wants to forward it. The file id is on the PhotoSize object and the bot does not need to download the photo before it forwards (sends) the photo another user.

To test this, we extend our previously written savePhoto method that it sends the received photo back to the sender by using the file id of the photo. First, the implementation of the sendPhotoFromFileId:

private void sendPhotoFromFileId(final String fileId, final Long chatId) {
    final SendPhoto sendPhotoRequest = new SendPhoto(); // 1
    sendPhotoRequest.setChatId(String.valueOf(chatId)); // 2
    sendPhotoRequest.setPhoto(new InputFile(fileId));   // 3
    try {
        execute(sendPhotoRequest);                      // 4
    } catch (final TelegramApiException e) {
        e.printStackTrace();
    }
}

Here you can see the modified savePhoto method, we just added one line:

@SuppressWarnings({"unused", "WeakerAccess"})
public Reply savePhoto() {
    return Reply.of(
        (bot, update) -> {
            final List<PhotoSize> photos = update.getMessage().getPhoto();
            final PhotoSize photoSize = photos.stream()
                    .max(Comparator.comparing(PhotoSize::getFileSize))
                    .orElse(null);
            if (photoSize != null) {
                final String filePath = getFilePath(photoSize);
                final File file = downloadPhoto(filePath);
                System.out.println("Temporary file: " + file);
                silent.send("Yeah, I got it!", getChatId(update));
                sendPhotoFromFileId(photoSize.getFileId(), getChatId(update));
            } else {
                silent.send("Houston, we have a problem!", getChatId(update));
            }
        },
        Flag.PHOTO);
}

Upload and send a Photo

This is so easy, you just need to specify a File object! The photo will be uploaded to Telegram and send to the user:

@SuppressWarnings({"unused", "WeakerAccess"})
public Ability sendIcon() {
    return Ability
        .builder()
        .name("icon")
        .info("send the icon")
        .locality(ALL)
        .privacy(PUBLIC)
        .action(context -> sendPhotoFromUpload("src/main/resources/chatbot.jpg", context.chatId()))
        .build();
}

private void sendPhotoFromUpload(final String filePath, final Long chatId) {
    final SendPhoto sendPhotoRequest = new SendPhoto();           // 1
    sendPhotoRequest.setChatId(String.valueOf(chatId));           // 2
    sendPhotoRequest.setPhoto(new InputFile(new File(filePath))); // 3
    try {
        execute(sendPhotoRequest);                                // 4
    } catch (final TelegramApiException e) {
        e.printStackTrace();
    }
}

Custom Keyboard (Buttons)

To create a custom keyboard, we have to follow these four steps:

  1. Create a ReplyKeyboardMarkup object

  2. Create the keyboard as a list of keyboard rows

  3. Add buttons to each row

  4. Activate the keyboard

In the following example we create a custom keyboard with two rows and three buttons on each row. If the user presses one of these buttons, the text will be send to the bot.

In our example we want to provide buttons for the actions of our bot so we use the command as button text:

@SuppressWarnings({"unused", "WeakerAccess"})
public Ability sendKeyboard() {
    return Ability
        .builder()
        .name("keyboard")
        .info("send a custom keyboard")
        .locality(ALL)
        .privacy(PUBLIC)
        .action(context -> {
            final SendMessage message = new SendMessage();
            message.setChatId(String.valueOf(context.chatId()));
            message.setText("Enjoy this wonderful keyboard!");

            final ReplyKeyboardMarkup keyboardMarkup = new ReplyKeyboardMarkup();
            final List<KeyboardRow> keyboard = new ArrayList<>();

            // row 1
            KeyboardRow row = new KeyboardRow();
            row.add("/hello");
            row.add("/hi");
            row.add("/count");
            keyboard.add(row);

            // row 2
            row = new KeyboardRow();
            row.add("/contacts");
            row.add("/logo");
            row.add("/icon");
            keyboard.add(row);

            // activate the keyboard
            keyboardMarkup.setKeyboard(keyboard);
            message.setReplyMarkup(keyboardMarkup);

            silent.execute(message);
        })
        .build();
}

Formatted Messages

To send formatted messages, you can use Markdown syntax. As of today, Telegram supports only a small subset of markdown. To activate Markdown support for a message, use sendMd(…) instead of just send(…).

@SuppressWarnings({"unused", "WeakerAccess"})
public Ability format() {
    return Ability
        .builder()
        .name("format")
        .info("formats the message")
        .locality(ALL)
        .privacy(PUBLIC)
        .action(context -> {
            silent.sendMd("You can make text *bold* or _italic_.", context.chatId());
            silent.sendMd("`This is code.`", context.chatId());
            silent.sendMd("```\nThis\nis\nmulti\nline\ncode.\n```", context.chatId());
        })
        .build();
}

Arguments

Commands can have arguments. Usually arguments are separated by whitespace. You can, of course, get the message and parse the arguments yourself. But with the ability bot you can easily access the arguments:

@SuppressWarnings({"unused", "WeakerAccess"})
public Ability add() {
    return Ability
        .builder()
        .name("add")
        .info("adds to numbers")
        .locality(ALL)
        .privacy(PUBLIC)
        .input(2)
        .action(context -> {
            final int a = Integer.parseInt(context.firstArg());
            final int b = Integer.parseInt(context.secondArg());
            final int sum = a + b;
            silent.send(String.format("The sum of %d and %d is %d", a, b, sum), context.chatId());
        })
        .build();
}

To automatically create error messages if the use has not specified the correct amount of arguments, you can configure the number of required arguments like in the example above: .input(2)

Default Abilities

You can answer to non-command messages, too. That’s what default abilities are for. Just specify an ability with the DEFAULT constant as command:

@SuppressWarnings({"unused", "WeakerAccess"})
public Ability sayNo() {
    return Ability.builder()
        .name(DEFAULT)
        .privacy(PUBLIC)
        .locality(ALL)
        .action(context -> silent.send("Sorry, I have no answer for you today.", context.chatId()))
        .build();
}

Testing

Dependencies

Better late than never – let’s talk about testing. For testing our bot we need the help of a mocking library. Please add the following dependencies to your project:

testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: '5.8.2'
testImplementation group: 'org.mockito', name: 'mockito-core', version: '4.5.1'

Prepare your Bot

In some of our abilities we use a database connection. We need to add an additional constructor to be able to inject a database for the tests:

@VisibleForTesting
WorkshopBot(final DBContext db) {
    super(BOT_TOKEN, BOT_USERNAME, db);
}

To prevent that the live system of Telegram is used, we need to inject mocks for the MessageSender and SilentSender. To be able to do this, we add the following two methods to our bot:

@VisibleForTesting
void setSender(final MessageSender sender) {
    this.sender = sender;
}

@VisibleForTesting
void setSilent(final SilentSender silent) {
    this.silent = silent;
}

Prepare the Test

  1. In some of our abilities we use a database connection. For the tests we create a separate database instance which will be deleted on JVM shutdown automatically.

  2. We create an instance of our bot and inject our test database into it.

  3. We need to mock the sender to prevent the use of the live Telegram API.

  4. We inject the sender into our bot.

  5. We create and inject the silent object into our bot.

public class WorkshopBotTest {

    private WorkshopBot bot;
    private DBContext db;
    private MessageSender sender;

    @Before
    public void setUp() {
        db = MapDBContext.offlineInstance("test"); // 1
        bot = new WorkshopBot(db);                 // 2
        sender = mock(MessageSender.class);        // 3
        bot.setSender(sender);                     // 4
        bot.setSilent(new SilentSender(sender));   // 5
    }

    @After
    public void tearDown() {
        db.clear();
    }

}

Simple Test

First, we take a very simple test case: Our "Hello World" example. The test for this ability would be:

private static final int USER_ID = 12345;
private static final long CHAT_ID = 12345L;

@Test
public void sayHelloWorld() throws TelegramApiException {
    final var mockedUpdate = mock(Update.class);
    final var user = new User(USER_ID, "Foo", false, "Bar", "foobar42", "en", false, false, false);
    final var context = MessageContext.newContext(mockedUpdate, user, CHAT_ID, bot);

    bot.sayHelloWorld().action().accept(context);

    final var message = new SendMessage();
    message.setChatId(String.valueOf(CHAT_ID));
    message.setText("Hello world");
    verify(sender, times(1)).execute(message);
}

In the first code block we mock the Update class, which is used by the context object. Then we create a User for our test case and create a new context object with all needed information.

The one line in the middle block executes our bot ability.

The last block does the assertions. In this example we check, that the message was sent exactly once to the correct chat. Therefore, we need a message object with the text and chat id for the verify method of Mockito.

Additional Exercises

  1. Instead of adding just text to the KeyboardRow object, try to use KeyboardButton objects.

  2. Add a button to send the users phone number to the bot.

  3. Add a button to send the current location of the user to the bot.

  4. Reply to the phone number and location with a confirmation message.

  5. In addition to using the ReplyKeyboardMarkup, take a look at ReplyKeyboardHide, ForceReply and InlineKeyboardMarkup and try to use them.

  6. Extend the /add command to accept an unlimited number of numbers.

  7. Write tests for all abilities of your bot.

  8. Refactor your bot into smaller classes (create smaller bots which focus on one topic)

  9. Tell the BotFather which commands are accepted by your bot.

  10. Refactor your bot to always inject a database. Specify the name of the database file.

  11. Refactor your bot to remove the hard coded username and token to avoid to accidentally commit them.

  12. Provide feedback to me about this workshop.