JavaRush /Java блог /Random UA /Telegram-бот як перший проект та його значущість для проф...
Pavel Mironov (Miroha)
16 рівень
Москва

Telegram-бот як перший проект та його значущість для професійного зростання на особистому досвіді

Стаття з групи Random UA
Вітаю всіх! Розповім трохи про себе. Мені 24 роки, минулого року закінчив технічний ВНЗ і досі не маю досвіду роботи. Забігаючи наперед, хочу сказати, що спочатку в закладеному плані (складеному восени 2019 року) планував вихід на роботу в березні-квітні 2020, але, на жаль, втрутився карантин, тому відклав все до середини літа і в майбутньому сподіваюся написати свою історію успіху. Telegram-бот як перший проект та його значущість для професійного зростання на особистому досвіді - 1До програмування мене ніколи не тягнуло. В університеті викладали програмування достатньо, але зацікавити мене це ремесло тоді не змогло. Були і процедурні мови (C), річний курс з ОВП (Java), бази даних, навіть асемблер та C++. Та чого таїти, до навчання в цілому я був байдужий, тому що більшість дисциплін, що викладаються, здавалися мені марними, придатними тільки для звітної відомості (в принципі це так і є). Після закінчення ВНЗ необхідно було вирішуватись: якихось навичок я не набув, а працювати треба. Довелося замислитися про самоосвіту (ох, як мінімум 2 повноцінних роки я вже прогав, сидівши склавши руки) і вибір сам упав на Java, тому що на курсі ОВП в університеті хтось із хлопців порадив курс codegym, а він, як ви знаєте присвячений саме мові Java. Зацікавило подання курсу. Так, я не любив тоді програмувати, бо одразу цю справу кидав, коли зустрічав якусь складність, а складнощів у програмуванні хоч греблю гати. Але в той же час я відчував, що хочу писати код, тому я вирішив зв'язати себе з програмуванням. Коротко розповім і про мій досвід на codegym. Почав я у серпні 2019, одразу купив передплату на місяць, але на 7 рівні зрозумів, що завдання даються важко. Відклав курс, взяв у руки Шілдта. Так паралельно і проходив курс упродовж 3 місяців. Дійшов до 20 рівня (це мій другий обліковий запис), майже повністю прочитав Шілдта, потім втомився від тутешніх завдань, у яких я перестав бачити практичну користь для себе. Заходив на codewars, leetcode, почав дивитися відеокурси. До речі, за 3 місяці я пройшов шлях від "Про ні, що таке масив? Як із ним працювати і чому так страшно" ? до детального вивчення вихідного коду класів колекцій (ArrayList, HashMap тощо). Грунтуючись на особистому досвіді, новачкам скажу: тут головне побороти таке почуття, яке виникає, якщо нічого не розумієш і нічого не можеш вирішити. Коли воно виникає, просто хочеться все кинути і здається, що ти надто тупий для цієї справи. Якщо переборювати такі моменти і морально відпочивати, то успіх прийде. Я думаю, що багато хто не справляється з цим, тому швидко кидає подібні починання. У результаті, у грудні 2019 року задумався про свій проект. Вирішив вибрати Telegram-бота, але ідеї не було. У той самий час одному знайомому знадобився функціонал своєї групи у телеграмі, що він хотів би автоматизувати. Він був у курсі, що я поглиблено вивчаю програмування і запропонував мені проект. Мені для досвіду та майбутнього резюме, йому - у розвиток групи. Я навіть дозволю собі процитувати його ідею: "Нещодавно софтину хотів у програміста замовити, яка б завантажувала в обрану Хмару файли за прямими посиланнями. Це цікаво, тому що аналогів немає. І дуже зручно. Суть: копіюєш посилання, вставляєш у вікно і вибираєш потрібну Хмару (GDrive, Mail, Яндекс Диск і т.п), свого часу софт все робить на стороні сервера і користувачеві нічого не потрібно завантажувати на свою машину (особливо круто, коли в тебе збирання на SSD-накопичувачах). Думали зробити в web-інтерфейсі, щоб можна було запускати як з телефонів, так і з робочого столу... Можна в принципі через додаток реалізувати, а не через web-інтерфейс. Тобі таке під силу?Таким чином, при кожному запиті бібліотека може розширюватися завдяки зусиллям користувачів. Надалі отримати інформацію про гру у зручному вигляді можна не заходячи до Google Play. Ви просто пишіть команду /library Тут_назва_ігри та отримуєте все, що потрібно. Але є кілька труднощів, про які я ще розповім. Спочатку просувався повільно, тому що паралельно почав проходити два курси по SQL. Банально було зрозуміти, як взагалі працює бот, і як обробляти запити. Зустрів товариша, якому теж було цікаво попрацювати над проектом. Перший варіант робота був готовий приблизно через місяць, але з товаришем виникли розбіжності (з мого боку). Я зайнявся частиною бота, яка відповідає за парсинг, а він безпосередньо працював над запитами до боту та їхньою обробкою. Він навіщось став ускладнювати робота, вводити якісь авторизації, вигадувати адмінів, додавати непотрібну функціональність, плюс мені не зовсім подобався стиль написання коду. На мій погляд, це не було потрібно в інформаційному боті. Так я вирішив, що напишу робота з нуля сам з необхідним мені функціоналом. Тепер розповім, що робить бот (з прикладу з коду проекту). Повний код проекту докладу наприкінці статті та, на жаль, повністю прокоментувати його фізично не зможу. Будь-яке повідомлення користувача, відправлене роботу, - це об'єкт класу Update. Він містить багато інформації (id повідомлення, id чату, унікальний id користувача тощо.). Є кілька типів update: це може бути текстове повідомлення, це може бути відповідь від телеграм-клавіатури (callback), фотографія, аудіо і т.д. Щоб користувач особливо не балувався, я обробляю лише текстові запити та callback'и від клавіатури. Якщо користувач надішле фотографію, робот його повідомить про те, що робити з нею він нічого не має наміру. У головному класі бота, в методі наUpdateReceived бот отримує апдейт.
@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-клавіатура виглядає так: Telegram-бот як перший проект та його значущість для професійного зростання на особистому досвіді - 1Вона задає кнопки, натиснувши на які користувач відправляє 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-клавіатура виглядає так: Telegram-бот як перший проект та його значущість для професійного зростання на особистому досвіді - 2І, по суті, вона замінює користувачеві набір тексту. Натиснувши кнопку "Бібліотека", ви швидко відправите повідомлення "Бібліотека" боту. Для кожного типу клавіатури я написав свій клас, реалізувавши патерн "Будівельник" (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 . У відповідь користувач отримає інформацію про гру у такому вигляді: Telegram-бот як перший проект та його значущість для професійного зростання на особистому досвіді - 3Разом з цим, буде викликаний метод, який спробує додати гру в бібліотеку бота (спочатку в локальну карту, потім -> 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:
  1. Реєструватися на heroku швидше за все доведеться за допомогою VPN, якщо ви живете в Росії.

  2. У корінь проекту необхідно покласти файл без розширення під назвою Procfile. Його вміст має бути таким: https://github.com/miroha/Telegram-Bot/blob/master/Procfile

  3. У pom.xml додати наступні рядки за зразком , де в тезі mainClass вказати шлях до класу, який містить main метод: bot.BotApplication (якщо клас BotApplication лежить у папці bot).

  4. Не здійснювати якихось складання проекту за допомогою команд mvn package і т.д., heroku все збере за вас сам.

  5. Бажано додати в проект gitignore, наприклад такий:

    # Log file
    *.log
    
    # Compiled resources
    target
    
    # Tests
    test
    
    # IDEA files
    .idea
    *.iml
  6. Власне завантажити проект на github, а далі підключити репозиторій в heroku (або використовуйте інші способи, там їх 3, якщо не помиляюся).

  7. Якщо завантаження пройшло успішно ("Build succeeded"), обов'язково зайдіть у Configure Dynos:

    Telegram-бот як перший проект та його значущість для професійного зростання на особистому досвіді - 4

    і перемкніть повзунок, а потім переконайтеся, що він у положенні ON (через те, що я це не зробив, мій бот не працював і я пару днів ламав голову і зробив дуже багато зайвих рухів тіла).

  8. Ховайте токен бота на гітхабі. Для цього необхідно отримувати токен із змінної оточення:

    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, туди і копіюйте токен вашого бота):

    Telegram-бот як перший проект та його значущість для професійного зростання на особистому досвіді - 5
Отже, за 2 місяці роботи над власним проектом я:
  • отримав повністю робочий проект, написаний Java;
  • навчився працювати зі стороннім API (Telegram Bot API);
  • на практиці заглибився в серіалізацію, багато попрацював із JSON та бібліотекою Jackson (спочатку використовував GSON, але з нею були проблеми);
  • зміцнив свої навички під час роботи з файлуми, познайомився з Java NIO;
  • навчився працювати з конфігураційними .xml файлуми та привчив себе до логування;
  • покращив володіння середовищем розробки (IDEA);
  • навчився працювати з git і пізнав цінність gitignore;
  • отримав навички у парсингу веб-сторінок (бібліотека Soup);
  • вивчив та використав кілька патернів проектування;
  • розвинув у собі почуття та бажання покращувати код (рефакторинг);
  • навчився знаходити рішення в мережі та не соромитися ставити запитання, на які не вдалося знайти відповіді.
Telegram-бот як перший проект та його значущість для професійного зростання на особистому досвіді - 7Я не знаю, наскільки корисним чи марним вийшов бот, наскільки красивий/некрасивий код, але досвід, який я отримав, напевно того коштував. В мене виникло почуття відповідальності за свій проект. Його постійно хочеться покращувати, додавати щось нове. Коли я зміг його запустити і переконатися, що все працює так, як я і хотів, я випробував справжній кайф. Хіба це не головне? Отримувати задоволення від того, чим ти займаєшся і радіти кожному працюючому рядку коду, як останній шоколадці. Тому якщо ви освоюєте програмування, то моя вам порада: не засиджуйтесь тут до 40 рівня, а починайте власний проект якомога раніше. Якщо комусь цікаво, вихідний код проекту знаходиться тут (переписаний під Spring): https://github.com/miroha/GooglePlayGames-TelegramBot Останні місяці два я майже не вивчаю новий матеріал, оскільки мені здається досяг глухого кута. Без роботи вже не бачу куди розвиватися, хіба що вчити Spring Framework, чим я і планую зайнятися найближчого місяця. А потім спробую переписати бота з використанням цього фреймворку. Готовий відповісти на будь-які запитання. :) Усім успіхів! UPDATE від 07.07.2020 Репозиторій з ботом на чистій Java був втрачений (я його видаляв, копія залишилася на іншій локальній машині), але завантажив переписаного бота під Spring Boot: https://github.com/miroha/GooglePlayGames-TelegramBot
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ