Copyright (c) 2018-2022 Marcus Fihlon
-
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
Use Telegram to talk to the BotFather
to register a new bot. Write down your bot username and your bot token.
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 \
.
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 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) {
}
}
The first method must return the username of your bot you wrote down while you registered your bot:
@Override
public String getBotUsername() {
return "MyWorkshopBot";
}
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";
}
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:
-
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. -
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.
For our simple example, we just a good old main
method. The start needs four steps:
-
We need an instance of the Telegram API
-
We need an instance of our bot
-
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
}
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 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;
}
}
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);
}
…
}
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
.
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();
}
-
the name of the command
-
a description of the command
-
the location of the command (
ALL
,USER
,GROUP
) -
the privacy setting (
PUBLIC
,GROUP_ADMIN
,ADMIN
,CREATOR
) -
the action to be executed
To start the ability bot we need to do exactly the same as with the bot, we created before:
-
We need an instance of the Telegram API
-
We need an instance of our bot
-
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
}
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 printhello - says hello world
. Yes! This is the information we supplied to the ability. The bot prints the commands in the format accepted byBotFather
. So, whenever you change, add or remove commands, you can simply send/commands
to your bot and forward that message toBotFather
. -
/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
A reply is AbilityBot’s swiss army knife. It comes in two variants and is able to handle all possible use cases.
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…
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.
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).
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.
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();
}
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.
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:
-
Create send method
-
Set destination chat id
-
Set the photo
-
Send the photo
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();
}
}
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);
}
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();
}
}
To create a custom keyboard, we have to follow these four steps:
-
Create a
ReplyKeyboardMarkup
object -
Create the keyboard as a list of keyboard rows
-
Add buttons to each row
-
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();
}
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();
}
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)
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();
}
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'
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;
}
-
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.
-
We create an instance of our bot and inject our test database into it.
-
We need to mock the sender to prevent the use of the live Telegram API.
-
We inject the sender into our bot.
-
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();
}
}
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.
-
Instead of adding just text to the
KeyboardRow
object, try to useKeyboardButton
objects. -
Add a button to send the users phone number to the bot.
-
Add a button to send the current location of the user to the bot.
-
Reply to the phone number and location with a confirmation message.
-
In addition to using the
ReplyKeyboardMarkup
, take a look atReplyKeyboardHide
,ForceReply
andInlineKeyboardMarkup
and try to use them. -
Extend the
/add
command to accept an unlimited number of numbers. -
Write tests for all abilities of your bot.
-
Refactor your bot into smaller classes (create smaller bots which focus on one topic)
-
Tell the
BotFather
which commands are accepted by your bot. -
Refactor your bot to always inject a database. Specify the name of the database file.
-
Refactor your bot to remove the hard coded username and token to avoid to accidentally commit them.
-
Provide feedback to me about this workshop.