Привет, Хабр! Сегодня я хочу рассказать о том, как писал Telegram-бота, да не простого, а подарочного. Прошу под кат тех, кому эта история кажется забавной, а также тех, кто пытается писать своих Telegram-ботов на Java. Возможно, мой небольшой опыт будет в чем-то полезен.
CLS_QUEST
– таблица, содержащая тексты вопросовCLS_QUEST_PHOTO
– таблица содержащая относительные пути к фотографиям, которые связаны с задаваемым вопросом; сами фотографии лежат в файловой системе в папках, соответствующих вопросу.CLS_ANSWER
– таблица, содержащая варианты ответов на вопрос, а также комментарии к каждому варианту ответа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)
);
org.telegram.telegrambots.bots.TelegramLongPollingBot
. 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
– токен для доступа к API Telegram, полученный на этапе регистрации бота.USERNAME
– имя бота, полученное на этапе регистрации бота.onUpdateReceived
вызывается при поступлении боту «входящих обновлений». В нашем боте нас интересует обработка текстовых команд (если быть честным, то только команды /start) и обработка колбэков (обратных вызовов), возникающих при нажатии на кнопки инлайн-клавиатуры (размещается в области сообщений). update.hasMessage() && update.getMessage().hasText()
или колбэком update.hasCallbackQuery()
, после чего вызывает соответствующие методы для обработки. О содержимом этих методов поговорим немного позже.
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);
}
}
}
}
AppEnv.getContext()
. На момент написания бота исправлять это было некогда, но в новых «поделках» удалось изжить этот велосипед и использовать вместо него Google Guice.processCommand
. 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));
answerMessage
. У сообщения устанавливается текст setText()
, включается поддержка некоторых html-тегов setParseMode("HTML")
и устанавливается идентификатор чата, в который сообщение будет отправлено setChatId(update.getMessage().getChatId())
. Осталось только добавить кнопку «Начать». Для этого сформируем инлайн-клавиатуру и добавим ее в ответ:SendMessage answerMessage = null;
String text = update.getMessage().getText();
if ("/start".equalsIgnoreCase(text)) {
answerMessage = new SendMessage();
answerMessage.setText("<b>Привет!" + smiling_face_with_heart_eyes +
"\nВо-первых с днем рождения!"
+ bouquet + bouquet + bouquet + party_popper
+ " А во-вторых, ты готова поиграть в увлекательную викторину?</b>");
answerMessage.setParseMode("HTML");
answerMessage.setChatId(update.getMessage().getChatId());
InlineKeyboardMarkup markup = keyboard(update);
answerMessage.setReplyMarkup(markup);
}
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;
}
public class InlineKeyboardButtonBuilder {
private final InlineKeyboardButton button;
public InlineKeyboardButtonBuilder(){
this.button = new InlineKeyboardButton();
}
public InlineKeyboardButtonBuilder setText(String text){
button.setText(text);
return this;
}
public InlineKeyboardButtonBuilder setCallbackData(String callbackData){
button.setCallbackData(callbackData);
return this;
}
public InlineKeyboardButton build(){
return button;
}
}
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;
}
}
ActionBuilder
мог вернуть JSON ему необходимо передать маршаллер. Здесь и далее при упоминании переменной marshaller подразумевается, что она является объектом класса, реализующего интерфейс DocumentMarshaller
.
public interface DocumentMarshaller {
<T> String marshal(T document);
<T> T unmarshal(String str);
<T> T unmarshal(String str, Class clazz);
}
ActionBuilder
, реализован с использованием Jackson. try {
if (answerMessage != null) {
execute(answerMessage);
}
} catch (TelegramApiException ex) {
Logger.getLogger(Bot.class.getName())
.log(Level.SEVERE, null, ex);
}
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;
}
}
ClassifierRepository
, а при упоминании переменной classifierRepository
подразумевается, что она является объектом класса, реализующего интерфейс 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);
}
processCallbackQuery()
. В начале метода обрабатывается входящее обновление, а также извлекаются данные колбэка. На основании данных колбэка определяется, было ли произведено нажатие на кнопку «Начать!» OPEN_MAIN.equals(action.getName()
, либо была нажата кнопка ответа на очередной вопрос. 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 (OPEN_MAIN.equals(action.getName())) {
initQuests(update);
sendQuest(update);
}
initQuests()
: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));
}
initQuests
сначала получим все 26 вопросов, а потом перемешаем в случайном порядке. После этого вопросы положим в QuestEnumeration
, откуда будем получать их по одному, до тех пор, пока не будут получены все 26 вопросов. QuestEnumeration
добавим в объект специального класса QuestStateHolder
, хранящего соответствие пользователя и его текущей сессии вопросов. Код классов QuestStateHolder
и 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;
}
}
CallbackData
кнопки, на которую было произведено нажатие):Long answId = Long.parseLong(action.getValue());
ClsAnswer answ = classifierRepository.find(ClsAnswer.class, answId);
SendMessage comment = new SendMessage();
comment.setParseMode("HTML");
comment.setText("<b>Твой ответ:</b> "
+ answ.getAnswerText()
+ "\n<b>Комментарий к ответу:</b> "
+ answ.getAnswerComment() + "\n");
comment.setChatId(chatId);
execute(comment);
sendQuest
, который отправляет очередной вопрос. Начинается все с получения очередного вопроса:QuestEnumeration qe = questStateHolder.get(update);
ClsQuest nextQuest = qe.nextElement();
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);
for (ClsQuestPhoto clsQuestPhoto : nextQuest.getClsQuestPhotoCollection()) {
SendPhoto sendPhoto = new SendPhoto();
sendPhoto.setChatId(chatId);
sendPhoto.setNewPhoto(new File("\\photo" + clsQuestPhoto.getRelFilePath()));
sendPhoto(sendPhoto);
}
SendMessage answers = new SendMessage();
answers.setParseMode("HTML");
answers.setText("<b>Варианты ответа:</b>");
answers.setChatId(chatId);
answers.setReplyMarkup(keyboardAnswer(update, nextQuest));
execute(answers);
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;
}
SendMessage answers = new SendMessage();
answers.setParseMode("HTML");
answers.setText("<b>Ну вот и все! Подробности на процедуре награждения</b> \n "
+ "Если хочешь начать заново нажми кнопку 'Начать' или введи /start");
answers.setChatId(chatId);
execute(answers);
SendSticker sticker = new SendSticker();
sticker.setChatId(chatId);
File stikerFile = new File("\\photo\\stiker.png");
sticker.setNewSticker(stikerFile);
sendSticker(sticker);
К сожалению, не доступен сервер mySQL