@Override
public void onUpdateReceived(Update update) {
UpdatesReceiver.handleUpdates(update);
}
який я передаю обробнику (власний клас UpdatesReceiver):
public static void handleUpdates(Update update) {
...
if (update.hasMessage() && update.getMessage().hasText()){
log.info("[Update (id {}) типа \"Текстовое сообщение\"]", update.getUpdateId());
new TextMessageHandler(update, replyGenerator).handleTextMessage();
}
else if (update.hasCallbackQuery()) {
//логгирование
new CallbackQueryHandler(update, replyGenerator).handleCallBackQuery();
}
else {
//логгирование
replyGenerator.sendTextMessage(update.getMessage().getChatId(), "Я могу принимать только текстовые повідомлення!");
}
}
UpdatesReceiver - це центральний обробник, який в залежності від типу апдейта передає керування в інший спеціалізований обробник: TextMessageHandler або CallbackQueryHandler, в конструктори яким я далі ланцюжком передаю update. Update - це найважливіше при роботі з роботом і його не можна втрачати, оскільки за допомогою інформації, що зберігається в апдейті, ми дізнаємося якому користувачеві і в який чат потрібно надсилати відповідь. Для створення відповідей користувачеві написав окремий клас. Він може надсилати звичайне текстове повідомлення, повідомлення з клавіатурою inline, повідомлення з картинкою і повідомлення з reply клавіатурою. Inline-клавіатура виглядає так: Вона задає кнопки, натиснувши на які користувач відправляє callback'і серверу, які можна обробляти майже таким же чином, що і звичайні повідомлення. Для її "підтримки" потрібний свій обробник. Ми задаємо для кожної кнопки певну дію, яка потім записується в об'єкт Update. Тобто. Для кнопки "Вартість" ми задали опис "/price" для callback'а, який надалі ми можемо отримати з апдейту. Далі в окремому класі, я вже можу обробити цей callback:
public void handleCallBackQuery() {
String call_data = update.getCallbackQuery().getData();
long message_id = update.getCallbackQuery().getMessage().getMessageId();
long chat_id = update.getCallbackQuery().getMessage().getChatId();
switch (call_date){
case "/price" :
//тут что-то сделать
break;
...
Reply-клавіатура виглядає так: І, по суті, вона замінює користувачеві набір тексту. Натиснувши кнопку "Бібліотека", ви швидко відправите повідомлення "Бібліотека" боту. Для кожного типу клавіатури я написав свій клас, реалізувавши патерн "Будівельник" (Builder): inline та reply . У підсумку можна по суті "намалювати" потрібну клавіатуру в залежності від вимог. Це дуже зручно, тому що клавіатури можуть бути різними, а принцип залишається тим же. Ось інтуїтивно зрозумілий метод для надсилання повідомлення з inline-клавіатурою:
public synchronized void sendInlineKeyboardMessage(long chat_id, String gameTitle) {
SendMessage keyboard = InlineKeyboardMarkupBuilder.create(chat_id)
.setText("Вы может узнать следующую информацию об игре " + gameTitle)
.row()
.button("Стоимость " + "\uD83D\uDCB0", "/price " + gameTitle)
.button("Обновлено " + "\uD83D\uDDD3", "/updated " + gameTitle)
.button("Версия " + "\uD83D\uDEE0", "/version " + gameTitle)
.endRow()
.row()
.button("Требования " + "\uD83D\uDCF5", "/requirements " + gameTitle)
.button("Покупки " + "\uD83D\uDED2", "/iap " + gameTitle)
.button("Размер " + "\uD83D\uDD0E", "/size " + gameTitle)
.endRow()
.row()
.button("Получить всю информацию об игре" + "\uD83D\uDD79", "/all " + gameTitle)
.endRow()
.row()
.button("Скрыть клавиатуру", "close")
.endRow()
.build();
try {
execute(keyboard);
} catch (TelegramApiException e) {
log.error("[Не удалось отправить сообщение с -inline- клавиатурой]: {}", e.getMessage());
}
}
Для надання роботі суворого функціоналу, були придумані спеціальні команди через символ слеша: /library, /help, /game і т.д. Інакше довелося б обробляти всяке сміття, яке могло написати користувач. Власне, для цього було написано MessageHandler:
if (message.equals(ChatCommands.START.getDescription())) {
replyGenerator.sendTextMessage(chat_id, new StartMessageHandler().reply());
replyGenerator.sendReplyKeyboardMessage(chat_id);
}
else if (message.equals(ChatCommands.HELP.getDescription())
|| message.equalsIgnoreCase("Помощь")) {
replyGenerator.sendTextMessage(chat_id, new HelpMessageHandler().reply());
}
...
Таким чином, залежно від того, яку команду ви відправите боту, в роботу включатиметься спеціальний обробник. Йдемо далі і розглянемо роботу парсера та бібліотеки. Якщо надіслати боту посилання на гру в магазині Google Play, автоматично спрацює спеціальний handler . У відповідь користувач отримає інформацію про гру у такому вигляді: Разом з цим, буде викликаний метод, який спробує додати гру в бібліотеку бота (спочатку в локальну карту, потім -> json файл). Якщо гра вже є в бібліотеці, буде здійснено перевірку (як у звичайній хешмапі), і якщо дані полів (наприклад, номер версії змінився), то гра в бібліотеці буде перезаписана. Якщо змін не буде виявлено, то ніяких записів здійснюватися не буде. Якщо гри в бібліотеці взагалі не було, то вона спочатку записується в локальну карту (об'єкт виду тик ), а потім пишеться в json файл, так як при непередбаченому закритті програми на сервері дані будуть втрачені, а за допомогою файлу їх завжди можна буде прочитати. Власне, бібліотека при старті програми завжди вперше завантажується з файлу зі static блоку:
static {
TypeFactory typeFactory = mapper.getTypeFactory();
MapType mapType = typeFactory.constructMapType(ConcurrentSkipListMap.class, String.class, GooglePlayGame.class);
try {
Path path = Paths.get(LIBRARY_PATH);
if (!Files.exists(path)) {
Files.createDirectories(path.getParent());
Files.createFile(path);
log.info("[Файл библиотеки создан]");
}
else {
ConcurrentMap<string, googleplaygame=""> temporary = mapper.readValue(new File(LIBRARY_PATH), mapType);
games.putAll(temporary);
log.info("[Количество игр в загруженной библиотеке] = " + games.size());
}
}
catch (IOException e) {
log.error("[Ошибка при чтении/записи файлу] {}", e.getMessage());
}
}
Тут додатково доводиться читати дані з файлу в тимчасову карту, яку потім копіювати в повноцінну, щоб зберегти нечутливість до регістру при пошуку гри у файлі (написавши tITan QuEST, бот все одно знайде гру Titan Quest в бібліотеці). Іншого рішення знайти не вдалося, такими є особливості десеріалізації з використанням Jackson. Отже, при кожному запиті за посиланням гра по можливості додається до бібліотеки, і бібліотека тим самим розширюється. Далі інформацію про конкретну гру можна дістати за командою /library Назва_Ігри.Можна дізнатися як певний параметр (наприклад, поточну версію), і всі параметри відразу. Це реалізовано за допомогою клавіатури inline, яка була розглянута раніше. У ході роботи застосовував і навички, здобуті тут під час вирішення завдань. Наприклад, список назв випадкових ігор, що знаходяться в бібліотеці (опція доступна за командою /library):
private String getRandomTitles(){
if (LibraryService.getLibrary().size() < 10){
return String.join("\n", LibraryService.getLibrary().keySet());
}
List<string> keys = new ArrayList<>(LibraryService.getLibrary().keySet());
Collections.shuffle(keys);
List<string> randomKeys = keys.subList(0, 10);
return String.join("\n", randomKeys);
}
Як бот обробляє посилання? Він перевіряє їх на приналежність до Google Play (хост, протокол, порт):
private static class GooglePlayCorrectURL {
private static final String VALID_HOST = "play.google.com";
private static final String VALID_PROTOCOL = "https";
private static final int VALID_PORT = -1;
private static boolean isLinkValid(URI link) {
return (isHostExist(link) && isProtocolExist(link) && link.getPort() == VALID_PORT);
}
private static boolean isProtocolExist(URI link) {
if (link.getScheme() != null) {
return link.getScheme().equals(VALID_PROTOCOL);
}
else {
return false;
}
}
private static boolean isHostExist(URI link) {
if (link.getHost() != null) {
return link.getHost().equals(VALID_HOST);
}
else {
return false;
}
}
Якщо все гаразд, то бот підключається за посиланням за допомогою бібліотеки Soup, яка дозволяє дістати HTML-код сторінки, що підлягає подальшому аналізу та парсингу. Обдурити бота неправильним або шкідливим посиланням не вдасться.
if (GooglePlayCorrectURL.isLinkValid(link)){
if (!link.getPath().contains("apps")){
throw new InvalidGooglePlayLinkException("К сожалению, бот работает исключительно с играми. Введите другую ссылку.");
}
URL = forceToRusLocalization(URL);
document = Jsoup.connect(URL).get();
}
else {
throw new NotGooglePlayLinkException();
}
...
Тут довелося вирішувати проблему з регіональними налаштуваннями. Робот підключається до магазину Google Play із сервера, який знаходиться в Європі, тому сторінка в магазині Google Play відкривається відповідною мовою. Довелося писати мабоцю, яка примусово здійснює "редирект" на російську версію сторінки (проект все-таки націлений був на нашу аудиторію). Для цього в кінці посилання потрібно акуратно дописати в GET запиті до Google Play параметр hl: &hl=ru .
private String forceToRusLocalization(String URL) {
if (URL.endsWith("&hl=ru")){
return URL;
}
else {
if (URL.contains("&hl=")){
URL = URL.replace(
URL.substring(URL.length()-"&hl=ru".length()), "&hl=ru");
}
else {
URL += "&hl=ru";
}
}
return URL;
}
Після вдалого підключення ми отримуємо HTML-документ, готовий для аналізу та парсингу, але це вже виходить за межі цієї статті. Код парсера тут. Власне парсер дістає потрібну інформацію та створює об'єкт з грою, який надалі у разі потреби додається до бібліотеки. <h2>Підсумок</h2>Бот підтримує кілька команд, в яких закладена певна функціональність. Він отримує повідомлення від користувача та зіставляє їх зі своїми командами. Якщо це посилання або команда /game + посилання, він перевіряє це посилання на приналежність Google Play. Якщо посилання коректне, він здійснює підключення за допомогою Soup та отримує HTML-документ. Цей документ аналізується на основі написаного парсера. З документа витягується необхідна інформація про гру, і далі об'єкт із грою заповнюється цими даними. Далі об'єкт з грою поміщається в локальне сховище (якщо ігри там ще немає) і відразу записується у файл для уникнення втрат даних. Записану в бібліотеку гру (назва гри – ключ для карти, об'єкт з грою – значення для карти), можна отримати за командою /library Назва_гри. Якщо вказана гра буде знайдена в бібліотеці бота, то користувачеві повернеться inline-клавіатура, за допомогою якої він може отримати інформацію про гру. Якщо ігри не знайдено, необхідно або переконатися в правильності написання назви (вона повинна повністю відповідати назві гри в магазині Google Play за винятком регістру), або додати гру до бібліотеки, надіславши боту посилання на гру. Деплой бота я здійснював на heroku і для тих, хто в майбутньому планує написати свого бота і розмістити його безкоштовно на heroku, дам кілька рекомендацій для вирішення труднощів, з якими ви можете зіткнутися (бо з ними я зіткнувся сам). На жаль, через особливості heroku, бібліотека робота постійно "обнулюється" раз на 24 години. Мій тариф не підтримує зберігання файлів на серверах heroku, тому він просто підтягує мій файл із іграми з гітхабу. Рішень було кілька: використовувати БД, або шукати інший сервер, який зберігав би цей файл із грою. Я поки що вирішив нічого не робити, тому що насправді бот не такий вже й корисний. Він мені був необхідний швидше для отримання повноцінного досвіду, чого я в принципі досяг. Отже, рекомендації з heroku: Він мені був необхідний швидше для отримання повноцінного досвіду, чого я в принципі досяг. Отже, рекомендації з heroku: Він мені був необхідний швидше для отримання повноцінного досвіду, чого я в принципі досяг. Отже, рекомендації з heroku:
-
Реєструватися на heroku швидше за все доведеться за допомогою VPN, якщо ви живете в Росії.
-
У корінь проекту необхідно покласти файл без розширення під назвою Procfile. Його вміст має бути таким: https://github.com/miroha/Telegram-Bot/blob/master/Procfile
-
У pom.xml додати наступні рядки за зразком , де в тезі mainClass вказати шлях до класу, який містить main метод: bot.BotApplication (якщо клас BotApplication лежить у папці bot).
-
Не здійснювати якихось складання проекту за допомогою команд mvn package і т.д., heroku все збере за вас сам.
-
Бажано додати в проект gitignore, наприклад такий:
# Log file *.log # Compiled resources target # Tests test # IDEA files .idea *.iml
-
Власне завантажити проект на github, а далі підключити репозиторій в heroku (або використовуйте інші способи, там їх 3, якщо не помиляюся).
-
Якщо завантаження пройшло успішно ("Build succeeded"), обов'язково зайдіть у Configure Dynos:
і перемкніть повзунок, а потім переконайтеся, що він у положенні ON (через те, що я це не зробив, мій бот не працював і я пару днів ламав голову і зробив дуже багато зайвих рухів тіла).
-
Ховайте токен бота на гітхабі. Для цього необхідно отримувати токен із змінної оточення:
public class Bot extends TelegramLongPollingBot { private static final String BOT_TOKEN = System.getenv("TOKEN"); @Override public String getBotToken() { return BOT_TOKEN; } ... }
А потім після деплою бота, задати цю змінну в dashboard'e heroku у вкладці Settings (праворуч від TOKEN буде поле VALUE, туди і копіюйте токен вашого бота):
- отримав повністю робочий проект, написаний Java;
- навчився працювати зі стороннім API (Telegram Bot API);
- на практиці заглибився в серіалізацію, багато попрацював із JSON та бібліотекою Jackson (спочатку використовував GSON, але з нею були проблеми);
- зміцнив свої навички під час роботи з файлуми, познайомився з Java NIO;
- навчився працювати з конфігураційними .xml файлуми та привчив себе до логування;
- покращив володіння середовищем розробки (IDEA);
- навчився працювати з git і пізнав цінність gitignore;
- отримав навички у парсингу веб-сторінок (бібліотека Soup);
- вивчив та використав кілька патернів проектування;
- розвинув у собі почуття та бажання покращувати код (рефакторинг);
- навчився знаходити рішення в мережі та не соромитися ставити запитання, на які не вдалося знайти відповіді.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ