How To Write a Quiz Chatbot on Telegram For Your Personal Needs?
Today we want to tell you about how we wrote a Chatbot on Telegram, and not just a simple one, but as the quiz one. Those to whom this story seems to be interesting, as well as those who are trying to write their Telegram-bots in Java.
Brief background
My name is Paul, and I have been working as a programmer for just over 9 years, I write mostly in Java, C#, Perl, Python, and sometimes 1C, I’m not considering myself as the super programmer, but I perform the assigned tasks.
In the spring of 2017, I was interested in creating bots for different messengers. At first, it seemed a good idea to create a bot in Viber. However, the creation of a public account turned out to be not such a simple task – all requests were laconic refusals.
After suffering from Weiber for about a week, I paid attention to Telegram, it turned out that it’s very easy to register a bot there, and the life of a Java programmer is facilitated by the presence of the telegram boys library. As a test of the pen was written a bot that allows you to receive some reports from corporate systems, then there was a bot with help information, and in autumn for myself, I tried to make a bot for pre-recording in the beauty salon. All these were interesting crafts, which were liked by others, and the first two bots were even really used in the work. Thanks to this, the mind was constantly thinking about how you can still use bots in everyday life and the national economy.
Preparation and creation the DB of question
At hand, there were sources of three other bots, which can be used as “parts”, and it was clear that in front of you waiting for an exciting rally on a bike from crutches.
Java was chosen as the programming language, TelegramBots was used as a library for working with the Telegram API, H2 DBMS was used to store the database of questions
The first task was to create a database of issues. To do this, I had to do a lot of work to collect photos from the phone, work and home computers and social networks. The received photos were structured in such a way that there were 26 questions, to each of which were attached from 2 to 4 photos and 4 variants of the answer. At the same time, there were no known correct answers, and the answer to each question was simply accompanied by a comment. I also wanted to save the history of the selected answers, but at the last moment, I just forgot to screw this feature.
Laying out photographs and thinking up questions turned out to be a very laborious process, and it took them one and a half hours.
Next, a database containing questions was implemented. The following is a description of the database tables and the DDL script.
- CLS_QUEST – table containing the texts of questions
- CLS_QUEST_PHOTO – a table containing relative paths to the photos that are associated with the question being asked; the pictures themselves lie in the file system in folders corresponding to the question.
- CLS_ANSWER – a table containing options for answering a question, as well as comments on each variant of the answer
DB Script
CREATE SCHEMA IF NOT EXISTS QUE; SET SCHEMA QUE; CREATE TABLE QUE.CLS_QUEST( ID BIGINT IDENTITY, IS_DELETED INT DEFAULT 0, QUEST_TEXT CLOB ); CREATE TABLE QUE.CLS_QUEST_PHOTO( ID BIGINT IDENTITY, ID_QUEST BIGINT NOT NULL, IS_DELETED INT DEFAULT 0, REL_FILE_PATH CLOB, PHOTO_TEXT CLOB, FOREIGN KEY(ID_QUEST) REFERENCES CLS_QUEST(ID) ); CREATE TABLE QUE.CLS_ANSWER( ID BIGINT IDENTITY, ID_QUEST BIGINT NOT NULL, IS_DELETED INT DEFAULT 0, ANSWER_TEXT CLOB, ANSWER_COMMENT CLOB, FOREIGN KEY(ID_QUEST) REFERENCES CLS_QUEST(ID) );
After the creation, the database was filled with data manually, a blessing in Netbeans, which I use as a development environment, a fairly convenient SQL script editor.
After two days, the database of questions and photos was ready, there was very little time left, it was time to move on to creating the bot itself.
The framework of the bot
I’ll remind you that to create a bot in Telegram, you need to write @BotFather using the /newsbot command to enter a display name and username for the bot. After these steps are completed, a token will be received to access the Telegram API.
For beauty, you can add a profile photo with /setuserpic.
Now let’s proceed to create the bot itself with the help of TelegramBots. Let me remind you that Telegram allows you to create bots working with:
The second option was chosen. To create a LongPolling bot, you must implement your own class that inherits from the org.telegram.telegrambots.bots.TelegramLongPollingBot class.
The source code
public class Bot extends TelegramLongPollingBot { private static final String TOKEN = "TOKEN"; private static final String USERNAME = "USERNAME"; public Bot() { } public Bot(DefaultBotOptions options) { super(options); } @Override public String getBotToken() { return TOKEN; } @Override public String getBotUsername() { return USERNAME; } @Override public void onUpdateReceived(Update update) { if (update.hasMessage() && update.getMessage().hasText()) { processCommand(update); } else if (update.hasCallbackQuery()) { processCallbackQuery(update); } } }
TOKEN – a token of access to the Telegram API, received at the bot registration stage.
USERNAME is the bot name received at the bot registration stage.
The onUpdateReceived method is called when the bot receives “incoming updates“. In our bot, we are interested in processing text commands (to be honest, only commands/start) and processing callbacks that occur when you press the buttons of the inline keyboard (located in the message area).
The bot checks whether the incoming update is a text message update.message () && update.getMessage (). HasText () or callback update.hasCallbackQuery (), and then calls the appropriate methods for processing. The content of these methods will be discussed a little later.
The bot being created is a normal console application and its launch looks like this:
The source code of main-class
public class Main { public static void main(String[] args) { ApiContextInitializer.init(); TelegramBotsApi botsApi = new TelegramBotsApi(); Runnable r = () -> { Bot bot = null; HttpHost proxy = AppEnv.getContext().getProxy(); if (proxy == null) { bot = new Bot(); } else { DefaultBotOptions instance = ApiContext .getInstance(DefaultBotOptions.class); RequestConfig rc = RequestConfig.custom() .setProxy(proxy).build(); instance.setRequestConfig(rc); bot = new Bot(instance); } try { botsApi.registerBot(bot); AppEnv.getContext().getMenuManager().setBot(bot); } catch (TelegramApiRequestException ex) { Logger.getLogger(Main.class.getName()) .log(Level.SEVERE, null, ex); } }; new Thread(r).start() while (true) { try { Thread.sleep(80000L); } catch (InterruptedException ex) { Logger.getLogger(Main.class.getName()) .log(Level.SEVERE, null, ex); } } } }
Nothing complicated in the initialization of the bot, but I want to note that it is important to provide an opportunity to specify the bot proxy. In our case, the proxy settings are stored in the usual properties file, from which they are read at the program start. Also, note that the application uses its own bad bike in the form of a kind of similar global context AppEnv.getContext (). At the time of writing the bot, there was no time to fix it, but in new “handicrafts” it was possible to get rid of this bicycle and use Google Guice instead.
Welcome message
The bot operation naturally begins with the processing of the /start command. As it was written above, this command is processed by the processCommand method.
At the beginning of the method, we will declare the smiles that will be used in the text of the welcome message.
final String smiling_face_with_heart_eyes = new String(Character.toChars(0x1F60D)); final String winking_face = new String(Character.toChars(0x1F609)); final String bouquet = new String(Character.toChars(0x1F490)); final String party_popper = new String(Character.toChars(0x1F389));
Next, the entered command is checked, and if this is the /start command, a response message of answer message is generated. The message sets the text setText (), enables support for some HTML-tags setParseMode (“HTML”), and sets the chat ID to which the message will be sent setChatId (update.getMessage (). GetChatId ()). It only remains to add the “Start” button. To do this, we will form an inline keyboard and add it in response:
SendMessage answerMessage = null; String text = update.getMessage (). GetText (); if ("/start.equalsIgnoreCase(text)) { answerMessage = new SendMessage (); answerMessage.setText ("<b> Hello!" + smiling_face_with_heart_eyes + "\ n First off happy birthday!" + bouquet + bouquet + bouquet + party_popper + "And secondly, are you ready to play an exciting quiz? </ B>"); answerMessage.setParseMode ("HTML"); answerMessage.setChatId (update.getMessage (). getChatId ()); InlineKeyboardMarkup markup = keyboard (update); answerMessage.setReplyMarkup (markup); }
The source code for forming the keyboard is shown below:
private InlineKeyboardMarkup keyboard(Update update) { final InlineKeyboardMarkup markup = new InlineKeyboardMarkup(); List<List<InlineKeyboardButton>> keyboard = new ArrayList<>(); keyboard.add(Arrays.asList(buttonMain())); markup.setKeyboard(keyboard); return markup; } private InlineKeyboardButton buttonMain() { final String OPEN_MAIN = "OM"; final String winking_face = new String(Character.toChars(0x1F609)); InlineKeyboardButton button = new InlineKeyboardButtonBuilder() .setText("Начать!" + winking_face) .setCallbackData(new ActionBuilder(marshaller) .setName(OPEN_MAIN) .asString()) .build(); return button; }
An interesting point is the installation of callback data. This data can be used to process button presses. In our case, the serialized JSON object is written to the callback data. This method is heavy for this task, but it allows you to work with return data without unnecessary problems for conversion. The return data is generated in a special ActionBuilder.
The Source code for ActionBuilder
public class Action { protected String name = ""; protected String id = ""; protected String value = ""; public String getName() { return name; } public void setName(String value) { this.name = value; } public String getValue() { return value; } public void setValue(String value) { this.value = value; } } public class ActionBuilder { private final DocumentMarshaller marshaller; private Action action = new Action(); public ActionBuilder(DocumentMarshaller marshaller) { this. marshaller = marshaller; } public ActionBuilder setName(String name) { action.setName(name); return this; } public ActionBuilder setValue(String name) { action.setValue(name); return this; } public String asString() { return marshaller.<Action>marshal(action, "Action"); } public Action build() { return action; } public Action build(Update update) { String data = update.getCallbackQuery().getData(); if (data == null) { return null; } action = marshaller.<Action>unmarshal(data, "Action"); if (action == null) { return null; } return action; } }
In order for ActionBuilder to return JSON, it must be passed to the Marshaller. Here and below, when referring to the marshaller variable, it is meant that it is an object of the class implementing the DocumentMarshaller interface.
The source code forDocumentMarshaller
public interface DocumentMarshaller { <T> String marshal(T document); <T> T unmarshal(String str); <T> T unmarshal(String str, Class clazz); }
Marshaller, which is used in ActionBuilder, is implemented using Jackson.
And, at last, the message is sent:
try { if (answerMessage != null) { execute(answerMessage); } } catch (TelegramApiException ex) { Logger.getLogger(Bot.class.getName()) .log(Level.SEVERE, null, ex); }
In the end, the greeting message looks like this.
Asking questions
It remained to make the most interesting – to implement the logic of the bot.
JPA was used to work with the database of questions. I’ll give the code for the entity classes.
The source code of entity classes
public abstract class Classifier implements Serializable { private static final long serialVersionUID = 1L; public Classifier() { } public abstract Long getId(); public abstract Integer getIsDeleted(); public abstract void setIsDeleted(Integer isDeleted); } @Entity @Table(name = "CLS_ANSWER", catalog = "QUEB", schema = "QUE") @XmlRootElement public class ClsAnswer extends Classifier implements Serializable { private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Basic(optional = false) @Column(name = "ID") private Long id; @Column(name = "IS_DELETED") private Integer isDeleted; @Lob @Column(name = "ANSWER_TEXT") private String answerText; @Lob @Column(name = "ANSWER_COMMENT") private String answerComment; @OneToMany(cascade = CascadeType.ALL, mappedBy = "idAnswer") private Collection<RegQuestAnswer> regQuestAnswerCollection; @JoinColumn(name = "ID_QUEST", referencedColumnName = "ID") @ManyToOne(optional = false) private ClsQuest idQuest; public ClsAnswer() { } public ClsAnswer(Long id) { this.id = id; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public Integer getIsDeleted() { return isDeleted; } public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } public String getAnswerText() { return answerText; } public void setAnswerText(String answerText) { this.answerText = answerText; } public String getAnswerComment() { return answerComment; } public void setAnswerComment(String answerComment) { this.answerComment = answerComment; } @XmlTransient public Collection<RegQuestAnswer> getRegQuestAnswerCollection() { return regQuestAnswerCollection; } public void setRegQuestAnswerCollection(Collection<RegQuestAnswer> regQuestAnswerCollection) { this.regQuestAnswerCollection = regQuestAnswerCollection; } public ClsQuest getIdQuest() { return idQuest; } public void setIdQuest(ClsQuest idQuest) { this.idQuest = idQuest; } } @Entity @Table(name = "CLS_QUEST", catalog = "QUEB", schema = "QUE") @XmlRootElement public class ClsQuest extends Classifier implements Serializable { private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Basic(optional = false) @Column(name = "ID") private Long id; @Column(name = "IS_DELETED") private Integer isDeleted; @Lob @Column(name = "QUEST_TEXT") private String questText; @OneToMany(cascade = CascadeType.ALL, mappedBy = "idQuest") private Collection<RegQuestAnswer> regQuestAnswerCollection; @OneToMany(cascade = CascadeType.ALL, mappedBy = "idQuest") private Collection<ClsAnswer> clsAnswerCollection; @OneToMany(cascade = CascadeType.ALL, mappedBy = "idQuest") private Collection<ClsQuestPhoto> clsQuestPhotoCollection; public ClsQuest() { } public ClsQuest(Long id) { this.id = id; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public Integer getIsDeleted() { return isDeleted; } public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } public String getQuestText() { return questText; } public void setQuestText(String questText) { this.questText = questText; } @XmlTransient public Collection<RegQuestAnswer> getRegQuestAnswerCollection() { return regQuestAnswerCollection; } public void setRegQuestAnswerCollection(Collection<RegQuestAnswer> regQuestAnswerCollection) { this.regQuestAnswerCollection = regQuestAnswerCollection; } @XmlTransient public Collection<ClsAnswer> getClsAnswerCollection() { return clsAnswerCollection; } public void setClsAnswerCollection(Collection<ClsAnswer> clsAnswerCollection) { this.clsAnswerCollection = clsAnswerCollection; } @XmlTransient public Collection<ClsQuestPhoto> getClsQuestPhotoCollection() { return clsQuestPhotoCollection; } public void setClsQuestPhotoCollection(Collection<ClsQuestPhoto> clsQuestPhotoCollection) { this.clsQuestPhotoCollection = clsQuestPhotoCollection; } } @Entity @Table(name = "CLS_QUEST_PHOTO", catalog = "QUEB", schema = "QUE") @XmlRootElement public class ClsQuestPhoto extends Classifier implements Serializable { private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Basic(optional = false) @Column(name = "ID") private Long id; @Column(name = "IS_DELETED") private Integer isDeleted; @Lob @Column(name = "REL_FILE_PATH") private String relFilePath; @Lob @Column(name = "PHOTO_TEXT") private String photoText; @JoinColumn(name = "ID_QUEST", referencedColumnName = "ID") @ManyToOne(optional = false) private ClsQuest idQuest; public ClsQuestPhoto() { } public ClsQuestPhoto(Long id) { this.id = id; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public Integer getIsDeleted() { return isDeleted; } public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } public String getRelFilePath() { return relFilePath; } public void setRelFilePath(String relFilePath) { this.relFilePath = relFilePath; } public String getPhotoText() { return photoText; } public void setPhotoText(String photoText) { this.photoText = photoText; } public ClsQuest getIdQuest() { return idQuest; } public void setIdQuest(ClsQuest idQuest) { this.idQuest = idQuest; } }
Also note that here and further to access the data, an object that implements the ClassifierRepository interface is used, and when the classified repository variable is mentioned it is meant that it is an object of the class implementing the ClassifierRepository interface.
The source code ClassifierRepository
public interface ClassifierRepository { <T extends Classifier> void add(T classifier); <T extends Classifier> List<T> find(Class<T> clazz); <T extends Classifier> T find(Class<T> clazz, Long id); <T extends Classifier> List<T> find(Class<T> clazz, boolean isDeleted); <T extends Classifier> List<T> getAll(Class<T> clazz); <T extends Classifier> List<T> getAll(Class<T> clazz, boolean isDeleted); }
Now let’s move to the point where the “Start!” The button is pressed. At this very moment, the bot processes the next portion of incoming information and calls the previously mentioned method processCallbackQuery (). At the beginning of the method, the incoming update is processed, and the callback data is also extracted. Based on the callback data, it is determined whether the “Start” button OPEN_MAIN.equals (action.getName () was pressed, or the answer button to the next question was pressed. GET_ANSWER.equals (action.getName ()).
final String OPEN_MAIN = "OM"; final String GET_ANSWER = "GA"; Action action = new ActionBuilder(marshaller).buld(update); String data = update.getCallbackQuery().getData(); Long chatId = update.getCallbackQuery().getMessage().getChatId();
If the quiz is just started, you need to initialize the list of questions and ask the first question.
if (OPEN_MAIN.equals(action.getName())) { initQuests(update); sendQuest(update); }
Now consider initializing the initQuests () list of questions:
private void initQuests(Update update) { QuestStateHolder questStateHolder = new QuestStateHolder(); List<ClsQuest> q = classifierRepository.find(ClsQuest.class, false); Collections.shuffle(q); questStateHolder.put(update, new QuestEnumeration(q)); }
In the initQuests method, we first get all 26 questions, and then we shuffle them in random order. After that, we’ll put the questions in QuestEnumeration, where we’ll get them one at a time until all 26 questions are received. QuestEnumeration we add to the object a special class QuestStateHolder, storing the user’s correspondence and its current session of questions. The QuestStateHolder and QuestEnumeration class codes are below.
The source code QuestStateHolder and QuestEnumeration
public class QuestStateHolder{ private Map<Integer, QuestEnumeration> questStates = new HashMap<>(); public QuestEnumeration get(User user) { return questStates.get(user.getId()) == null ? null : questStates.get(user.getId()); } public QuestEnumeration get(Update update) { User u = getUserFromUpdate(update); return get(u); } public void put(Update update, QuestEnumeration questEnumeration) { User u = getUserFromUpdate(update); put(u, questEnumeration); } public void put(User user, QuestEnumeration questEnumeration) { questStates.put(user.getId(), questEnumeration); } static User getUserFromUpdate(Update update) { return update.getMessage() != null ? update.getMessage().getFrom() : update.getCallbackQuery().getFrom(); } } public class QuestEnumeration implements Enumeration<ClsQuest>{ private List<ClsQuest> quests = new ArrayList<>(); private Integer currentQuest = 0; public QuestEnumeration(List<ClsQuest> quests){ this.quests.addAll(quests); } @Override public boolean hasMoreElements() { return currentQuest < quests.size(); } @Override public ClsQuest nextElement() { ClsQuest q = null; if (hasMoreElements()){ q = quests.get(currentQuest); currentQuest++; } return q; } public Integer getCurrentQuest(){ return currentQuest; } }
After the initialization, the first question is asked. But we’ll talk about this a little later. In the meantime, consider the situation when the answer to the already asked question came and the bot should send a comment pertaining to this variant of the answer. Here everything is simple enough, we first look for the answer in the database (the unique identifier of the answer variant is stored in the CallbackData of the button on which the click was made):
Long answId = Long.parseLong(action.getValue()); ClsAnswer answ = classifierRepository.find(ClsAnswer.class, answId);
Then we prepare a message based on the answer found and send it:
SendMessage comment = new SendMessage(); comment.setParseMode("HTML"); comment.setText("<b>Твой ответ:</b> " + answ.getAnswerText() + "\n<b>Комментарий к ответу:</b> " + answ.getAnswerComment() + "\n"); comment.setChatId(chatId); execute(comment);
Now consider the sendQuest method, which sends the next question. Everything starts with the receipt of the next question:
QuestEnumeration qe = questStateHolder.get(update); ClsQuest nextQuest = qe.nextElement();
If the Enumeration still contains elements, then we are preparing the question for sending, otherwise, it’s time to print a message about the end of the quiz. We send the question:
Long chatId = update.getCallbackQuery().getMessage().getChatId(); SendMessage quest = new SendMessage(); quest.setParseMode("HTML"); quest.setText("<b>Вопрос " + qe.getCurrentQuest() + ":</b> " + nextQuest.getQuestText()); quest.setChatId(chatId); execute(quest);
Now we send photos related to this issue:
for (ClsQuestPhoto clsQuestPhoto : nextQuest.getClsQuestPhotoCollection()) { SendPhoto sendPhoto = new SendPhoto(); sendPhoto.setChatId(chatId); sendPhoto.setNewPhoto(new File("\\photo" + clsQuestPhoto.getRelFilePath())); sendPhoto(sendPhoto); }
And, finally, the answers:
SendMessage answers = new SendMessage(); answers.setParseMode("HTML"); answers.setText("<b>Варианты ответа:</b>"); answers.setChatId(chatId); answers.setReplyMarkup(keyboardAnswer(update, nextQuest)); execute(answers);
The keyboard with the answer options is formed as follows
The source code
private InlineKeyboardMarkup keyboardAnswer(Update update, ClsQuest quest) { final InlineKeyboardMarkup markup = new InlineKeyboardMarkup(); List<List<InlineKeyboardButton>> keyboard = new ArrayList<>(); for (ClsAnswer clsAnswer : quest.getClsAnswerCollection()) { keyboard.add(Arrays.asList(buttonAnswer(clsAnswer))); } markup.setKeyboard(keyboard); return markup; } private InlineKeyboardButton buttonAnswer(ClsAnswer clsAnswer) { InlineKeyboardButton button = new InlineKeyboardButtonBuilder() .setText(clsAnswer.getAnswerText()) .setCallbackData(new ActionBuilder(marshaller) .setName(GET_ANSWER) .setValue(clsAnswer.getId().toString()) .asString()) .build(); return button; }
At the moment when the questions have ended, a message will be generated about the end of the quiz:
SendMessage answers = new SendMessage (); answers.setParseMode ("HTML"); answers.setText ("<b> Well that's all! Details on the award procedure </ b> \ n" + "If you want to start over again, click the 'Start' button or type / start"); answers.setChatId (chatId); execute (answers);
And at the very end we will send a funny sticker:
SendSticker sticker = new SendSticker(); sticker.setChatId(chatId); File stikerFile = new File("\\photo\\stiker.png"); sticker.setNewSticker(stikerFile); sendSticker(sticker);
Conclusion
The bot itself was accepted with interest and liked, so I did my best. Also, I want to note that the very idea of bots is very promising. There are a lot of everyday tasks from ordering a pizza to calling a taxi, which is offered to solve through mobile applications, sites, and their mobile versions or phone calls to the operator.
On the one hand, all these methods have proved their effectiveness, are convenient and are unlikely to change in the near future. On the other hand, applications for mobile devices, although rich in functionality, require installation, updating and learning their interface, and also have the ability to eat battery. Sites and their mobile versions require from the user at least a transition to the browser and work with the new interface, which is not always convenient, especially on mobile devices. Interaction on the phone is convenient for many but does not imply any visualization in principle, and in addition, the operator will always be the bottleneck of the system.
When solving the same tasks, bots do not require the installation of anything other than a messenger, do not require the study of new interfaces and allow the user to work asynchronously (unlike a phone call) in a relatively familiar messenger interface. The messenger, in this case, provides a certain environment for client-server interaction, where the bot acts as the server, and the client part is implemented by the messenger.
Of course, when working with bots for the user also there are various difficulties, in something similar to the difficulties with working with textual interfaces, in some way caused by the limitations of the messengers themselves. But all the same, bots that are focused on solving small daily tasks (or for entertainment, like the bot described in this article) seem promising.