JavaRush /Java блог /Random UA /Telegram bot - нагадування через webHook на Java або скаж...
Vladimir Popov
41 рівень

Telegram bot - нагадування через webHook на Java або скажи ні Google-календарю! Частина 2

Стаття з групи Random UA
Друга частина проекту - ось посилання на першу: І так клас BotState : Для того, щоб наш бот розумів, що від нього чекають у певний момент часу, наприклад видалення нагадування, нам необхідно якось дати знати нашому боту що введені та відправлені зараз цифри варто сприймати як ID нагадування зі списку, його треба видалити. Тому після натискання на кнопку «Видалити» бот перетворюється на стан BotState.ENTERNUMBEREVENT , це спеціально створений клас Enum зі станами бота.
public enum BotState {
    ENTERDESCRIPTION,//the bot will wait for the description to be entered.
    START,
    MYEVENTS, //the bot show to user list events.
    ENTERNUMBEREVENT,//the bot will wait for the number of event to be entered.
    ENTERDATE, //the bot will wait for the date to be entered
    CREATE, //the bot run created event
    ENTERNUMBERFOREDIT, //the bot will wait for the number of event to be entered
    EDITDATE, //the bot will wait for the date to be entered
    EDITDESCRIPTION,//the bot will wait for the description to be entered
    EDITFREQ,//the bot will wait callbackquery
    ALLUSERS, // show all users
    ALLEVENTS, //show all events
    ENTERNUMBERUSER,//the bot will wait for the number of user to be entered.
    ENTERTIME,//the bot will wait for the hour to be entered.
    ONEVENT // state toggle
}
І тепер від нас очікують введення цифр - " Введіть номер нагадування зі списку ." Після введення яких вони потраплять у необхідний спосіб обробки. Ось наш перемикач стану:
public class BotStateCash {
    private final Map<long, botstate=""> botStateMap = new HashMap<>();

    public void saveBotState(long userId, BotState botState) {
        botStateMap.put(userId, botState);
    }
}

</long,>
Звичайна карта з ID користувача та його станом. Поле int adminId це для мене ) Далі логіка методу handleUpdate перевірять, що це за повідомлення? Сallbackquery чи просто текст? Якщо це звичайний текст, то ми вирушаємо в метод handleInputMessage , де ми обробляємо кнопки основного меню, і, якщо на них натиснули, то встановлюємо потрібний стан, якщо не натиснули і це незнайомий текст, то встановлюємо стан з кешу, якщо його немає, то встановлюємо стартовий стан. Далі текст переходить в обробку методу handle з потрібним нам станом. Тепер наведемо логіку класу MessageHandler , який відповідає за обробку повідомлень залежно від стану робота:
public class MessageHandler {

    private final UserDAO userDAO;
    private final MenuService menuService;
    private final EventHandler eventHandler;
    private final BotStateCash botStateCash;
    private final EventCash eventCash;

    public MessageHandler(UserDAO userDAO, MenuService menuService, EventHandler eventHandler, BotStateCash botStateCash, EventCash eventCash) {
        this.userDAO = userDAO;
        this.menuService = menuService;
        this.eventHandler = eventHandler;
        this.botStateCash = botStateCash;
        this.eventCash = eventCash;
    }

    public BotApiMethod<!--?--> handle(Message message, BotState botState) {
        long userId = message.getFrom().getId();
        long chatId = message.getChatId();
        SendMessage sendMessage = new SendMessage();
        sendMessage.setChatId(String.valueOf(chatId));
        //if new user
        if (!userDAO.isExist(userId)) {
            return eventHandler.saveNewUser(message, userId, sendMessage);
        }
        //save state in to cache
        botStateCash.saveBotState(userId, botState);
        //if state =...
        switch (botState.name()) {
            case ("START"):
                return menuService.getMainMenuMessage(message.getChatId(),
                        "Воспользуйтесь главным меню", userId);
            case ("ENTERTIME"):
                //set time zone user. for correct sent event
                return eventHandler.enterLocalTimeUser(message);
            case ("MYEVENTS"):
                //list events of user
                return eventHandler.myEventHandler(userId);
            case ("ENTERNUMBEREVENT"):
                //remove event
                return eventHandler.removeEventHandler(message, userId);
            case ("ENTERDESCRIPTION"):
                //enter description for create event
                return eventHandler.enterDescriptionHandler(message, userId);
            case ("ENTERDATE"):
                //enter date for create event
                return eventHandler.enterDateHandler(message, userId);
            case ("CREATE"):
                //start create event, set state to next step
                botStateCash.saveBotState(userId, BotState.ENTERDESCRIPTION);
                //set new event to cache
                eventCash.saveEventCash(userId, new Event());
                sendMessage.setText("Введите описание события");
                return sendMessage;
            case ("ENTERNUMBERFOREDIT"):
                //show to user selected event
                return eventHandler.editHandler(message, userId);
            case ("EDITDESCRIPTION"):
                //save new description in database
                return eventHandler.editDescription(message);
            case ("EDITDATE"):
                //save new date in database
                return eventHandler.editDate(message);
            case ("ALLEVENTS"):
                //only admin
                return eventHandler.allEvents(userId);
            case ("ALLUSERS"):
                //only admin
                return eventHandler.allUsers(userId);
            case ("ONEVENT"):
                // on/off notification
                return eventHandler.onEvent(message);
            case ("ENTERNUMBERUSER"):
                //only admin
                return eventHandler.removeUserHandler(message, userId);
            default:
                throw new IllegalStateException("Unexpected value: " + botState);
        }
    }
}
у методі handle ми перевіряємо з яким станом до нас надійшло повідомлення та направляємо його в обробник подій – клас EventHandler. Тут у нас з'явилося два нових класи, MenuService та EventCash . MenuService – тут ми створюємо всі наші меню. EventCash - аналогічно як і BotStateCash буде зберігати частини нашої події після введення і коли введення буде завершено, ми збережемо подію в базі.
@Service
@Setter
@Getter
// used to save entered event data per session
public class EventCash {

    private final Map<long, event=""> eventMap = new HashMap<>();

    public void saveEventCash(long userId, Event event) {
        eventMap.put(userId, event);
    }
}
</long,>
Ну, тобто. коли ми наживаємо створити подію, в кеш створюється новий об'єкт Event-eventCash.saveEventCash(userId, new Event()); Потім ми вводимо опис події і додаємо його в кеш:
Event event = eventCash.getEventMap().get(userId);
event.setDescription(description);
//save to cache
eventCash.saveEventCash(userId, event);
Потім вводимо число:
Event event = eventCash.getEventMap().get(userId);
event.setDate(date);
//save data to cache
eventCash.saveEventCash(userId, event);
Клас CallbackQueryHandler аналогічний MessageHandler , тільки ми обробляємо там callbackquery-повідомлення. Повністю розбирати логіку роботи з подіями – EventHandler не має сенсу, вже й так занадто багато літер, вона зрозуміла за назвами методів та коментарями в коді. І повністю його викладати текстом сенсу не бачу, там понад 300 рядків. Ось посилання на клас у Github . Те саме стосується і класу MenuService , де ми створюємо наші меню. Про них можна детально почитати на сайті виробника бібліотеки. /api Тепер нам залишилося найсмачніше. Це клас для обробки повідомленьEventService :
@EnableScheduling
@Service
public class EventService {
    private final EventDAO eventDAO;
    private final EventCashDAO eventCashDAO;

    @Autowired
    public EventService(EventDAO eventDAO, EventCashDAO eventCashDAO) {
        this.eventDAO = eventDAO;
        this.eventCashDAO = eventCashDAO;
    }

    //start service in 0:00 every day
    @Scheduled(cron = "0 0 0 * * *")
    // @Scheduled(fixedRateString = "${eventservice.period}")
    private void eventService() {
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(new Date());
        int day = calendar.get(Calendar.DAY_OF_MONTH);
        int month = calendar.get(Calendar.MONTH);
        int year = calendar.get(Calendar.YEAR);

        //get event list is now date
        List<event> list = eventDAO.findAllEvent().stream().filter(event -> {
            if (event.getUser().isOn()) {
                EventFreq eventFreq = event.getFreq();

                //set user event time
                Calendar calendarUserTime = getDateUserTimeZone(event);

                int day1 = calendarUserTime.get(Calendar.DAY_OF_MONTH);
                int month1 = calendarUserTime.get(Calendar.MONTH);
                int year1 = calendarUserTime.get(Calendar.YEAR);
                switch (eventFreq.name()) {
                    case "TIME": //if one time - remove event
                        if (day == day1 && month == month1 && year == year1) {
                            eventDAO.remove(event);
                            return true;
                        }
                    case "EVERYDAY":
                        return true;
                    case "MONTH":
                        if (day == day1) return true;
                    case "YEAR":
                        if (day == day1 && month == month1) return true;
                    default: return false;
                }
            } else return false;
        }).collect(Collectors.toList());

        for (Event event : list) {
            //set user event time
            Calendar calendarUserTime = getDateUserTimeZone(event);
            int hour1 = calendarUserTime.get(Calendar.HOUR_OF_DAY);
            calendarUserTime.set(year, month, day, hour1, 0, 0);

            String description = event.getDescription();
            String userId = String.valueOf(event.getUser().getId());

            //save the event to the database in case the server reboots.
            EventCashEntity eventCashEntity = EventCashEntity.eventTo(calendarUserTime.getTime(), event.getDescription(), event.getUser().getId());
            eventCashDAO.save(eventCashEntity);

            //create a thread for the upcoming event with the launch at a specific time
            SendEvent sendEvent = new SendEvent();
            sendEvent.setSendMessage(new SendMessage(userId, description));
            sendEvent.setEventCashId(eventCashEntity.getId());

            new Timer().schedule(new SimpleTask(sendEvent), calendarUserTime.getTime());
        }
    }

    private Calendar getDateUserTimeZone(Event event) {
        Calendar calendarUserTime = Calendar.getInstance();
        calendarUserTime.setTime(event.getDate());
        int timeZone = event.getUser().getTimeZone();

        //set correct event time with user timezone
        calendarUserTime.add(Calendar.HOUR_OF_DAY, -timeZone);
        return calendarUserTime;
    }
}

</event>
@EnableScheduling – включаємо роботу з розкладу у Spring. @Scheduled(cron = "0 0 0 * * *") – налаштовуємо запуск методу о 0:00 щодня calendar.setTime(new Date()); - Встановлюємо серверний час. Отримуємо список нагадувань на сьогодні, за допомогою магії стриму та лямбду. Проходимо по отриманому списку, встановлюємо правильний час відправки calendarUserTime і… Ось тут я вирішив викрутитись і запускати процеси відкладено за часом. За це у нас відповідає в java клас Time . new Timer().schedule(new SimpleTask(sendEvent), calendarUserTime.getTime()); Для нього нам необхідно створити нитку:
public class SendEvent extends Thread {


    private long eventCashId;
    private SendMessage sendMessage;

    public SendEvent() {
    }

    @SneakyThrows
    @Override
    public void run() {
        TelegramBot telegramBot = ApplicationContextProvider.getApplicationContext().getBean(TelegramBot.class);
        EventCashDAO eventCashDAO = ApplicationContextProvider.getApplicationContext().getBean(EventCashDAO.class);
        telegramBot.execute(sendMessage);
        //if event it worked, need to remove it from the database of unresolved events
        eventCashDAO.delete(eventCashId);
    }
}
та реалізацію TimerTask
public class SimpleTask extends TimerTask {
    private final SendEvent sendEvent;

    public SimpleTask(SendEvent sendEvent) {
        this.sendEvent = sendEvent;
    }

    @Override
    public void run() {
        sendEvent.start();
    }
}
Так, я чудово розумію, що можна кожні 20 хвабон проходити по базі та розсилати повідомлення, але я на самому початку все написав про це)) Тут ще ми стикаємося з жмотством Heroku №1. На безкоштовному тарифі Вам дається якісь 550 dino, це щось на кшталт годин роботи вашої програми на місяць. На повний місяць роботи цього не вистачить, а ось якщо прив'язати карту, то Вам дається ще 450 dino, що вистачає за очі. Якщо переживаєте за карту, можете прив'язати якусь порожню, але щоб там було 0,6 $. Це перевірна сума, вона просто повинна бути на рахунку. Жодних прихованих списань не проводиться тільки якщо ви самі не зміните тариф. На безкоштовному тарифі, є ще одне невелике поджмотство, назвемо його №1а. загалом воно перезавантажується щодня десь опівночі МСК, а буває і в інший час. Від цього всі наші процеси у пам'яті віддаляються. Для вирішення цієї проблеми я вигадав таблицю EventCash. Перед надсиланням події зберігаються в окрему таблицю:
EventCashEntity eventCashEntity = EventCashEntity.eventTo(calendarUserTime.getTime(), event.getDescription(), event.getUser().getId());
eventCashDAO.save(eventCashEntity);
А після відправлення, видаляються:
@Override
public void run() {
    TelegramBot telegramBot = ApplicationContextProvider.getApplicationContext().getBean(TelegramBot.class);
    EventCashDAO eventCashDAO = ApplicationContextProvider.getApplicationContext().getBean(EventCashDAO.class);
    telegramBot.execute(sendMessage);
    //if event it worked, need to remove it from the database of unresolved events
    eventCashDAO.delete(eventCashId);
}
ApplicationContextProvider – це спеціальний клас для отримання контексту на льоту:
@Component
//wrapper to receive Beans
public class ApplicationContextProvider implements ApplicationContextAware {

    private static ApplicationContext context;

    public static ApplicationContext getApplicationContext() {
        return context;
    }

    @Override
    public void setApplicationContext(ApplicationContext ac)
            throws BeansException {
        context = ac;
    }
}
Для перевірки на невідпрацьовані події я зробив спеціальний сервіс, в якому є метод, помічений @ PostConstruct – він запускається після кожного старту програми. Він піднімає всі невідпрацьовані події з бази та повертає їх на згадку. Ось тобі шкідливий Heroku!
@Component
public class SendEventFromCache {

    private final EventCashDAO eventCashDAO;
    private final TelegramBot telegramBot;

    @Value("${telegrambot.adminId}")
    private int admin_id;

    @Autowired
    public SendEventFromCache(EventCashDAO eventCashDAO, TelegramBot telegramBot) {
        this.eventCashDAO = eventCashDAO;
        this.telegramBot = telegramBot;
    }

    @PostConstruct
    @SneakyThrows
    //after every restart app  - check unspent events
    private void afterStart() {
        List<eventcashentity> list = eventCashDAO.findAllEventCash();

        SendMessage sendMessage = new SendMessage();
        sendMessage.setChatId(String.valueOf(admin_id));
        sendMessage.setText("Произошла перезагрузка!");
        telegramBot.execute(sendMessage);

        if (!list.isEmpty()) {
            for (EventCashEntity eventCashEntity : list) {
                Calendar calendar = Calendar.getInstance();
                calendar.setTime(eventCashEntity.getDate());
                SendEvent sendEvent = new SendEvent();
                sendEvent.setSendMessage(new SendMessage(String.valueOf(eventCashEntity.getUserId()), eventCashEntity.getDescription()));
                sendEvent.setEventCashId(eventCashEntity.getId());
                new Timer().schedule(new SimpleTask(sendEvent), calendar.getTime());
            }
        }
    }
}
</eventcashentity>
Наша програма готова, і час нам отримати адресау на Heroku для додатку та бази даних. Ваш додаток має бути викладено на Github! Заходимо на Heroku.com Натискаємо Create New App , вводимо свою назву програми, вибираємо Europe, create app . Все, місце для програми готове. Якщо натиснете open App, то браузер перенаправить вас на адресау вашої програми, це і є ваша адресаа webhook - https://ваша_назва.herokuapp.com/ Реєструйте його в telegram, а в налаштуваннях application.propertie s змінюйте telegrambot.webHookPath=https: //telegrambotsimpl.herokuapp.com/ на свій server.port=5000 Ви можете видалити або закоментувати. Тепер підключимо базу даних. Заходимо у вкладку Resources на Heroku, натискаємо: Telegram bot - нагадування через webHook на Java або скажи ні Google-календарю!  Частина 2: - 1 Знаходимо там Heroku Postgres , натискаємо install : Вас перенаправить на сторінку кабінету вашої бази даних. Знайдіть там у Settings/ Telegram bot - нагадування через webHook на Java або скажи ні Google-календарю!  Частина 2: - 2 Там будуть усі необхідні дані від вашої бази. У application.properties тепер все має бути так:
#server.port=5000

telegrambot.userName=@calendar_event_bot
telegrambot.botToken=1731265488:AAFDjUSk3vu5SFfgdfh556gOOFmuml7SqEjwrmnEF5Ak
telegrambot.webHookPath=https://telegrambotsimpl.herokuapp.com/
#telegrambot.webHookPath=https://f5d6beeb7b93.ngrok.io


telegrambot.adminId=39376213

eventservice.period =600000

#spring.datasource.driver-class-name=org.postgresql.Driver
#spring.datasource.url=jdbc:postgresql://localhost:5432/telegramUsers
#spring.datasource.username=postgres
#spring.datasource.password=password

spring.datasource.url=jdbc:postgresql:ec2-54-247-158-179.eu-west-1.compute.amazonaws.com:5432/d2um26le5notq?ssl=true&sslmode=require&sslfactory=org.postgresql.ssl.NonValidatingFactory
spring.datasource.username=ulmbeymwyvsxa
spring.datasource.password=4c7646c69dbgeacbk98fa96e8daa6d9b1bl4894e67f3f3ddd6a27fe7b0537fd
Замініть дані з кабінету на свої: У полі jdbc:postgresql:ec2-54-247-158-179.eu-west-1.compute.amazonaws.com:5432/d2um26le5notq?ssl=true&sslmode=require&sslfactory= .NonValidatingFactory потрібно замінити виділене жирним шрифтом на відповідні дані з кабінету (Host, Database) Поля username, password не важко здогадатися. Тепер нам потрібно створити таблиці у базі, я це робив з IDEA. Стане в нагоді наш скрипт для створення бази. Додаємо базу даних як це було написано Вище: Telegram bot - нагадування через webHook на Java або скажи ні Google-календарю!  Частина 2: - 3 Поле Host, User, Password, Database беремо з кабінету. Поле URL – це наше поле spring.datasource.url до знака питання. Заходимо у вкладку Advanced , там має бути так: Telegram bot - нагадування через webHook на Java або скажи ні Google-календарю!  Частина 2: - 4 Якщо Ви все зробабо правильно, то після натискання на test буде зелененька галочка. Натискаємо ОК. Натискаємо правою кнопкою на нашу базу і вибираємо Jump to query console . Копіюйте туди наш script і натискайте на execute . База має утворитися. Вам доступно 10 000 рядків нахаляву! Все готове до Deploy. Переходимо в наш додаток на Heroku у розділі Deploy. Вибираємо розділ Github: Telegram bot - нагадування через webHook на Java або скажи ні Google-календарю!  Частина 2: - 5 Прив'язуємо свій репозиторій до Heroku. Тепер Ваші гілки будуть видні. Не забудьте зробити push Ваших останніх змін у .properties. Нижче вибираєте гілку, яка завантажуватиметься, і натискаємо Deploy branch . Якщо все зроблено правильно, то Вам буде повідомлено, що програма успішно розгорнута. Не забудьте увімкнути Automatic deploys from.. Щоб Ваша програма запускалася автоматично. До речі, коли ви робитимете push змін на GitHub, heroku буде автоматично перезапускати програму. Обережно ставтеся до цього, заведіть окрему гілку для знущань, а основну використовуйте тільки для робочої програми. Тепер Жмотство №2! Полягає у всьому відомому мінусі безкоштовного тарифу на heroku. За відсутності повідомлень, що надходять, програма переходить в режим standby, і після надходження повідомлення буде досить тривалий час запускатися, що не приємно. Для цього існує просте рішення – https://uptimerobot.com/ І ні, примочки з пінгом Google не допоможуть, взагалі не знаю, звідки ця інфа взялася, я гуглив це питання, і вже років 10 як не працює ця тема точно, якщо взагалі працювала. Даний додаток буде на встановлений вами час відсилати HEAD-запити на вказану вами адресау та у разі, якщо вона не відповідає, надсилати повідомлення на email. Розібратися не складе Вам труднощів, там мало кнопок, щоб заплутатися)) Вітаю!! Якщо я нічого не забув і Ви були уважні, то у Вас власний додаток, який працює на халяву і ніколи не падає. Перед Вами відкривається можливість для знущань та експериментів. У будь-якому випадку я готовий відповідати на запитання та прийму будь-яку критику! Код: https://github.com/papoff8295/webHookBotForHabr Використані матеріали: https://tlgrm.ru/docs/bots/api – про боти. https://en.wikibooks. org/wiki/Java_Persistence - про відносини у базах даних. https://stackoverflow.com/questions/11432498/how-to-call-a-thread-to-run-on-specific-time-in-java - клас Time та TimerTask https://www.youtube.com/ watch?v=CUDgSbaYGx4 – як викласти код на Github https://github.com/rubenlagus/TelegramBots - бібліотека telegram та купа корисного про це.
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ