From b94a313403b6e6ebff56440127211f08dd2fb3ed Mon Sep 17 00:00:00 2001 From: ndmik <255708774+ndmik-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:13:19 +0200 Subject: [PATCH 1/5] add group resolving for kyiv --- .../ndmik/bot/handler/GroupBackHandler.java | 1 + .../ndmik/bot/handler/GroupClickHandler.java | 1 + .../ndmik/bot/handler/GroupDoneHandler.java | 1 + .../bot/handler/GroupResolvingHandler.java | 52 +++++++++- .../bot/handler/GroupSelectionHandler.java | 25 ++++- .../ndmik/bot/handler/RegionsBackHandler.java | 4 + .../ndmik/bot/model/entity/UserSettings.java | 2 + .../ua/ndmik/bot/service/TelegramService.java | 5 + .../service/YasnoGroupResolverService.java | 7 ++ .../ndmik/bot/telegram/DtekShutdownBot.java | 66 ++++++++++++- .../ua/ndmik/bot/util/AddressQueryParser.java | 50 ++++++++++ ...waiting_address_input_to_user_settings.sql | 2 + .../bot/telegram/DtekShutdownBotTests.java | 94 +++++++++++++++++++ .../bot/util/AddressQueryParserTests.java | 35 +++++++ 14 files changed, 338 insertions(+), 7 deletions(-) create mode 100644 src/main/java/ua/ndmik/bot/util/AddressQueryParser.java create mode 100644 src/main/resources/db/migration/V4__add_awaiting_address_input_to_user_settings.sql create mode 100644 src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java create mode 100644 src/test/java/ua/ndmik/bot/util/AddressQueryParserTests.java diff --git a/src/main/java/ua/ndmik/bot/handler/GroupBackHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupBackHandler.java index b3c9843..cc3d707 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupBackHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupBackHandler.java @@ -23,6 +23,7 @@ public void handle(Update update) { UserSettings user = userRepository.findByChatId(chatId) .orElseThrow(() -> new RuntimeException(String.format("User not found for chatId=%s", chatId))); user.setTmpGroupId(null); + user.setAwaitingAddressInput(false); userRepository.save(user); groupSelectionHandler.handle(update); } diff --git a/src/main/java/ua/ndmik/bot/handler/GroupClickHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupClickHandler.java index 4463565..d6c5bc5 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupClickHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupClickHandler.java @@ -27,6 +27,7 @@ public void handle(Update update) { .orElseThrow(() -> new RuntimeException(String.format("User not found for chatId=%s", chatId))); user.setTmpGroupId(payload.groupId()); user.setTmpArea(payload.area()); + user.setAwaitingAddressInput(false); userRepository.save(user); regionHandler.reprint( update, diff --git a/src/main/java/ua/ndmik/bot/handler/GroupDoneHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupDoneHandler.java index 437197e..59e6125 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupDoneHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupDoneHandler.java @@ -36,6 +36,7 @@ public void handle(Update update) { } user.setGroupId(groupId); user.setArea(area); + user.setAwaitingAddressInput(false); userRepository.save(user); int messageId = update.getCallbackQuery().getMessage().getMessageId(); diff --git a/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java index d04c979..35c020f 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java @@ -1,15 +1,61 @@ package ua.ndmik.bot.handler; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardRow; +import ua.ndmik.bot.model.Message; +import ua.ndmik.bot.model.entity.UserSettings; +import ua.ndmik.bot.repository.UserSettingsRepository; +import ua.ndmik.bot.service.TelegramService; + +import java.util.List; + +import static ua.ndmik.bot.model.MenuCallback.GROUP_SELECTION; @Component -@Slf4j public class GroupResolvingHandler implements CallbackHandler { + private final UserSettingsRepository userRepository; + private final TelegramService telegramService; + + public GroupResolvingHandler(UserSettingsRepository userRepository, + TelegramService telegramService) { + this.userRepository = userRepository; + this.telegramService = telegramService; + } + @Override public void handle(Update update) { - log.info("HANDLER FOR THIS ACTION IS NOT IMPLEMENTED"); + long chatId = getChatId(update); + UserSettings user = userRepository.findByChatId(chatId) + .orElseThrow(() -> new RuntimeException(String.format("User not found for chatId=%s", chatId))); + + user.setAwaitingAddressInput(true); + userRepository.save(user); + + InlineKeyboardMarkup menu = telegramService.menu(List.of( + new InlineKeyboardRow(List.of( + telegramService.button("⬅️ До вибору регіону", GROUP_SELECTION.name()) + )) + )); + + int messageId = update.getCallbackQuery().getMessage().getMessageId(); + Message message = new Message( + messageId, + chatId, + """ + 📍 Надішліть адресу одним повідомленням. + Функція працює лише для адрес у м. Київ. + + Формат: + • вул. Хрещатик, 22 + • вул. Хрещатик 22 + + Я знайду вашу групу відключень і збережу її. + """, + menu + ); + telegramService.editMessage(message); } } diff --git a/src/main/java/ua/ndmik/bot/handler/GroupSelectionHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupSelectionHandler.java index 03a13db..4527a9d 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupSelectionHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupSelectionHandler.java @@ -5,6 +5,8 @@ import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardRow; import ua.ndmik.bot.model.Message; +import ua.ndmik.bot.model.entity.UserSettings; +import ua.ndmik.bot.repository.UserSettingsRepository; import ua.ndmik.bot.service.TelegramService; import java.util.List; @@ -15,25 +17,34 @@ public class GroupSelectionHandler implements CallbackHandler { private final TelegramService telegramService; + private final UserSettingsRepository userRepository; - public GroupSelectionHandler(TelegramService telegramService) { + public GroupSelectionHandler(TelegramService telegramService, + UserSettingsRepository userRepository) { this.telegramService = telegramService; + this.userRepository = userRepository; } @Override public void handle(Update update) { + long chatId = getChatId(update); + userRepository.findByChatId(chatId).ifPresent(this::resetAwaitingAddressInput); + InlineKeyboardRow regions = new InlineKeyboardRow( List.of( telegramService.button("🏙️ Київ", KYIV.name()), telegramService.button("🏘️ Київщина", REGION.name()) )); + InlineKeyboardRow resolveByAddress = new InlineKeyboardRow( + List.of( + telegramService.button("📍 Знайти групу за адресою (Київ)", GROUP_RESOLVING.name()) + )); InlineKeyboardRow back = new InlineKeyboardRow( List.of( telegramService.button("⬅️ Назад", REGIONS_BACK.name()) )); - InlineKeyboardMarkup menu = telegramService.menu(List.of(regions, back)); + InlineKeyboardMarkup menu = telegramService.menu(List.of(regions, resolveByAddress, back)); - long chatId = getChatId(update); int messageId = update.getCallbackQuery().getMessage().getMessageId(); Message message = new Message( messageId, @@ -43,4 +54,12 @@ public void handle(Update update) { ); telegramService.editMessage(message); } + + private void resetAwaitingAddressInput(UserSettings user) { + if (!user.isAwaitingAddressInput()) { + return; + } + user.setAwaitingAddressInput(false); + userRepository.save(user); + } } diff --git a/src/main/java/ua/ndmik/bot/handler/RegionsBackHandler.java b/src/main/java/ua/ndmik/bot/handler/RegionsBackHandler.java index 149c22f..7cc9f71 100644 --- a/src/main/java/ua/ndmik/bot/handler/RegionsBackHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/RegionsBackHandler.java @@ -26,6 +26,10 @@ public void handle(Update update) { long chatId = getChatId(update); UserSettings user = userRepository.findByChatId(chatId) .orElseThrow(() -> new RuntimeException(String.format("User not found for chatId=%s", chatId))); + if (user.isAwaitingAddressInput()) { + user.setAwaitingAddressInput(false); + userRepository.save(user); + } String groupId = user.getGroupId(); int messageId = update.getCallbackQuery().getMessage().getMessageId(); Message message; diff --git a/src/main/java/ua/ndmik/bot/model/entity/UserSettings.java b/src/main/java/ua/ndmik/bot/model/entity/UserSettings.java index dc4a0f4..341ec88 100644 --- a/src/main/java/ua/ndmik/bot/model/entity/UserSettings.java +++ b/src/main/java/ua/ndmik/bot/model/entity/UserSettings.java @@ -26,5 +26,7 @@ public class UserSettings { @Enumerated(EnumType.STRING) private DtekArea tmpArea; + private boolean awaitingAddressInput; + private boolean isNotificationEnabled; } diff --git a/src/main/java/ua/ndmik/bot/service/TelegramService.java b/src/main/java/ua/ndmik/bot/service/TelegramService.java index 2411dc1..fd19cfc 100644 --- a/src/main/java/ua/ndmik/bot/service/TelegramService.java +++ b/src/main/java/ua/ndmik/bot/service/TelegramService.java @@ -67,6 +67,10 @@ public TelegramService(@Value("${telegram.bot-token}") String botToken, public void sendGreeting(Update update) { UserSettings user = getOrCreateUser(update); + if (user.isAwaitingAddressInput()) { + user.setAwaitingAddressInput(false); + userRepository.save(user); + } InlineKeyboardMarkup menu = buildMainMenuMarkup(user); Message message = new Message( null, @@ -198,6 +202,7 @@ private UserSettings createNewUser(Long chatId) { log.info("Creating new user with chatId={}", chatId); UserSettings user = userRepository.save(UserSettings.builder() .chatId(chatId) + .awaitingAddressInput(false) .isNotificationEnabled(true) .build()); statsService.recordNewUser(); diff --git a/src/main/java/ua/ndmik/bot/service/YasnoGroupResolverService.java b/src/main/java/ua/ndmik/bot/service/YasnoGroupResolverService.java index dc42672..137f216 100644 --- a/src/main/java/ua/ndmik/bot/service/YasnoGroupResolverService.java +++ b/src/main/java/ua/ndmik/bot/service/YasnoGroupResolverService.java @@ -4,6 +4,7 @@ import ua.ndmik.bot.client.YasnoClient; import ua.ndmik.bot.model.AddressItem; import ua.ndmik.bot.model.ResolvedYasnoGroup; +import ua.ndmik.bot.util.AddressQueryParser; import java.util.List; import java.util.Locale; @@ -21,6 +22,12 @@ public YasnoGroupResolverService(YasnoClient yasnoClient) { this.yasnoClient = yasnoClient; } + public Optional resolveByAddress(String addressQuery) { + // Current YASNO integration is limited to Kyiv city addresses. + return AddressQueryParser.parse(addressQuery) + .flatMap(address -> resolve(address.streetQuery(), address.houseQuery())); + } + public Optional resolve(String streetQuery, String houseQuery) { List streets = yasnoClient.findStreets(KYIV_REGION_ID, KYIV_DSO_ID, streetQuery); Optional street = pickBest(streets, streetQuery); diff --git a/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java b/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java index 5e98b39..4916dd6 100644 --- a/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java +++ b/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java @@ -8,8 +8,15 @@ import org.telegram.telegrambots.longpolling.util.LongPollingSingleThreadUpdateConsumer; import org.telegram.telegrambots.meta.api.objects.Update; import ua.ndmik.bot.handler.CallbackHandlerResolver; +import ua.ndmik.bot.model.DtekArea; import ua.ndmik.bot.model.MenuCallback; +import ua.ndmik.bot.model.ResolvedYasnoGroup; +import ua.ndmik.bot.model.entity.UserSettings; +import ua.ndmik.bot.repository.UserSettingsRepository; import ua.ndmik.bot.service.TelegramService; +import ua.ndmik.bot.service.YasnoGroupResolverService; + +import java.util.Optional; @Service @Profile("!test") @@ -18,13 +25,19 @@ public class DtekShutdownBot implements SpringLongPollingBot, LongPollingSingleT private final String botToken; private final TelegramService telegramService; private final CallbackHandlerResolver callbackHandlerResolver; + private final UserSettingsRepository userRepository; + private final YasnoGroupResolverService yasnoGroupResolverService; public DtekShutdownBot(@Value("${telegram.bot-token}") String botToken, TelegramService telegramService, - CallbackHandlerResolver callbackHandlerResolver) { + CallbackHandlerResolver callbackHandlerResolver, + UserSettingsRepository userRepository, + YasnoGroupResolverService yasnoGroupResolverService) { this.botToken = botToken; this.telegramService = telegramService; this.callbackHandlerResolver = callbackHandlerResolver; + this.userRepository = userRepository; + this.yasnoGroupResolverService = yasnoGroupResolverService; } @Override @@ -61,7 +74,10 @@ private void handleMessage(Update update) { } if (isCommand(text, "/stats_week")) { telegramService.sendWeeklyStats(update.getMessage().getChatId()); + return; } + + handleAddressLookup(update, text); } private void handleCallback(Update update) { @@ -83,4 +99,52 @@ private void handleCallback(Update update) { private boolean isCommand(String text, String command) { return text.equals(command) || text.startsWith(command + "@"); } + + private void handleAddressLookup(Update update, String text) { + long chatId = update.getMessage().getChatId(); + Optional userOptional = userRepository.findByChatId(chatId); + if (userOptional.isEmpty()) { + return; + } + + UserSettings user = userOptional.get(); + if (!user.isAwaitingAddressInput()) { + return; + } + + Optional resolved; + try { + resolved = yasnoGroupResolverService.resolveByAddress(text); + } catch (RuntimeException ex) { + telegramService.sendUpdate( + user, + """ + ⚠️ Не вдалося обробити адресу. + Надішліть адресу у форматі «вулиця, будинок». + Підтримуються лише адреси у м. Київ. + """ + ); + return; + } + + if (resolved.isEmpty()) { + telegramService.sendUpdate( + user, + """ + ⚠️ Групу за цією адресою не знайдено. + Перевірте формат «вулиця, будинок», наприклад: «вул. Хрещатик, 22». + Працює лише для адрес у м. Київ. + """ + ); + return; + } + + user.setGroupId(resolved.get().groupId()); + user.setArea(DtekArea.KYIV); + user.setTmpGroupId(null); + user.setTmpArea(null); + user.setAwaitingAddressInput(false); + userRepository.save(user); + telegramService.sendUpdate(user, "✅ Групу для м. Київ знайдено і збережено: %s".formatted(user.getGroupId())); + } } diff --git a/src/main/java/ua/ndmik/bot/util/AddressQueryParser.java b/src/main/java/ua/ndmik/bot/util/AddressQueryParser.java new file mode 100644 index 0000000..c2e287b --- /dev/null +++ b/src/main/java/ua/ndmik/bot/util/AddressQueryParser.java @@ -0,0 +1,50 @@ +package ua.ndmik.bot.util; + +import java.util.Optional; + +public final class AddressQueryParser { + + private AddressQueryParser() { + } + + public static Optional parse(String rawAddress) { + if (rawAddress == null) { + return Optional.empty(); + } + + String normalized = rawAddress.trim().replaceAll("\\s+", " "); + if (normalized.isBlank()) { + return Optional.empty(); + } + + int commaIndex = normalized.lastIndexOf(','); + if (commaIndex > 0 && commaIndex < normalized.length() - 1) { + return buildAddressQuery( + normalized.substring(0, commaIndex), + normalized.substring(commaIndex + 1) + ); + } + + int lastSpaceIndex = normalized.lastIndexOf(' '); + if (lastSpaceIndex <= 0 || lastSpaceIndex >= normalized.length() - 1) { + return Optional.empty(); + } + + return buildAddressQuery( + normalized.substring(0, lastSpaceIndex), + normalized.substring(lastSpaceIndex + 1) + ); + } + + private static Optional buildAddressQuery(String streetPart, String housePart) { + String street = streetPart.trim(); + String house = housePart.trim(); + if (street.isBlank() || house.isBlank()) { + return Optional.empty(); + } + return Optional.of(new AddressQuery(street, house)); + } + + public record AddressQuery(String streetQuery, String houseQuery) { + } +} diff --git a/src/main/resources/db/migration/V4__add_awaiting_address_input_to_user_settings.sql b/src/main/resources/db/migration/V4__add_awaiting_address_input_to_user_settings.sql new file mode 100644 index 0000000..3663703 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_awaiting_address_input_to_user_settings.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_settings + ADD COLUMN awaiting_address_input INTEGER NOT NULL DEFAULT 0; diff --git a/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java b/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java new file mode 100644 index 0000000..df4889c --- /dev/null +++ b/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java @@ -0,0 +1,94 @@ +package ua.ndmik.bot.telegram; + +import org.junit.jupiter.api.Test; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.message.Message; +import ua.ndmik.bot.handler.CallbackHandlerResolver; +import ua.ndmik.bot.model.DtekArea; +import ua.ndmik.bot.model.ResolvedYasnoGroup; +import ua.ndmik.bot.model.entity.UserSettings; +import ua.ndmik.bot.repository.UserSettingsRepository; +import ua.ndmik.bot.service.TelegramService; +import ua.ndmik.bot.service.YasnoGroupResolverService; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +class DtekShutdownBotTests { + + private final TelegramService telegramService = mock(TelegramService.class); + private final CallbackHandlerResolver callbackHandlerResolver = mock(CallbackHandlerResolver.class); + private final UserSettingsRepository userRepository = mock(UserSettingsRepository.class); + private final YasnoGroupResolverService yasnoGroupResolverService = mock(YasnoGroupResolverService.class); + private final DtekShutdownBot bot = new DtekShutdownBot( + "TEST_TOKEN", + telegramService, + callbackHandlerResolver, + userRepository, + yasnoGroupResolverService + ); + + @Test + void consume_resolvesAndSavesGroupWhenUserIsAwaitingAddressInput() { + long chatId = 123L; + UserSettings user = UserSettings.builder() + .chatId(chatId) + .awaitingAddressInput(true) + .build(); + ResolvedYasnoGroup resolved = new ResolvedYasnoGroup( + 25, + 902, + 1L, + "Хрещатик", + 2L, + "22", + "29.1" + ); + given(userRepository.findByChatId(chatId)).willReturn(Optional.of(user)); + given(yasnoGroupResolverService.resolveByAddress("вул. Хрещатик, 22")).willReturn(Optional.of(resolved)); + + bot.consume(messageUpdate(chatId, "вул. Хрещатик, 22")); + + assertThat(user.getGroupId()).isEqualTo("29.1"); + assertThat(user.getArea()).isEqualTo(DtekArea.KYIV); + assertThat(user.isAwaitingAddressInput()).isFalse(); + then(userRepository).should().save(user); + then(telegramService).should().sendUpdate(eq(user), contains("✅")); + } + + @Test + void consume_doesNotResolveAddressWhenUserIsNotAwaitingInput() { + long chatId = 456L; + UserSettings user = UserSettings.builder() + .chatId(chatId) + .awaitingAddressInput(false) + .build(); + given(userRepository.findByChatId(chatId)).willReturn(Optional.of(user)); + + bot.consume(messageUpdate(chatId, "вул. Хрещатик, 22")); + + then(yasnoGroupResolverService).should(never()).resolveByAddress(anyString()); + then(userRepository).should(never()).save(user); + then(telegramService).should(never()).sendUpdate(eq(user), anyString()); + } + + private Update messageUpdate(long chatId, String text) { + Update update = mock(Update.class); + Message message = mock(Message.class); + given(update.hasMessage()).willReturn(true); + given(update.getMessage()).willReturn(message); + given(message.hasText()).willReturn(true); + given(message.getText()).willReturn(text); + given(message.getChatId()).willReturn(chatId); + given(update.hasCallbackQuery()).willReturn(false); + return update; + } +} diff --git a/src/test/java/ua/ndmik/bot/util/AddressQueryParserTests.java b/src/test/java/ua/ndmik/bot/util/AddressQueryParserTests.java new file mode 100644 index 0000000..52ce6d4 --- /dev/null +++ b/src/test/java/ua/ndmik/bot/util/AddressQueryParserTests.java @@ -0,0 +1,35 @@ +package ua.ndmik.bot.util; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class AddressQueryParserTests { + + @Test + void parse_splitsAddressByComma() { + Optional parsed = AddressQueryParser.parse("вул. Хрещатик, 22"); + + assertThat(parsed).isPresent(); + assertThat(parsed.get().streetQuery()).isEqualTo("вул. Хрещатик"); + assertThat(parsed.get().houseQuery()).isEqualTo("22"); + } + + @Test + void parse_splitsAddressByLastSpaceWhenCommaMissing() { + Optional parsed = AddressQueryParser.parse("вул. Богдана Хмельницького 11Б"); + + assertThat(parsed).isPresent(); + assertThat(parsed.get().streetQuery()).isEqualTo("вул. Богдана Хмельницького"); + assertThat(parsed.get().houseQuery()).isEqualTo("11Б"); + } + + @Test + void parse_returnsEmptyWhenAddressCannotBeSplit() { + Optional parsed = AddressQueryParser.parse("Хрещатик"); + + assertThat(parsed).isEmpty(); + } +} From 16aa4fe803f4fbebfdd89578ed4d686701f56d77 Mon Sep 17 00:00:00 2001 From: ndmik <255708774+ndmik-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:15:59 +0200 Subject: [PATCH 2/5] update readme --- README.md | 89 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 1ffd52c..39baedb 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,56 @@ # Firefly -Telegram bot built with Spring Boot that fetches DTEK outage schedules and sends updates to users. +Telegram bot (Spring Boot) that fetches DTEK outage schedules for Kyiv and Kyiv region, stores user settings in SQLite, and sends schedule updates. ## Stack - Java 25 -- Spring Boot 4 -- SQLite + Spring Data JPA +- Spring Boot 4.0.1 +- SQLite + Spring Data JPA + Flyway - Telegram Bots API (long polling) - Playwright + Jsoup for DTEK data extraction -## Features -- `/start` flow with interactive inline menu -- Admin stats commands: `/stats_today`, `/stats_week` -- Region/group selection and persistence per chat -- Notification toggle per user -- Scheduled refresh of outage schedules -- Daily rollover of `tomorrow` schedules into `today` +## Bot Features +- `/start` with inline menu and per-user settings persistence. +- Manual group selection by region: + - `🏙️ Київ` + - `🏘️ Київщина` +- Group auto-detection by address via YASNO: + - trigger: `📍 Знайти групу за адресою (Київ)` + - supported formats: `вул. Хрещатик, 22` or `вул. Хрещатик 22` + - current scope: **only city Kyiv addresses** + - resolved group is saved for the user with `area=KYIV` +- Notification toggle per user. +- Admin stats commands: + - `/stats_today` + - `/stats_week` +- Scheduler: + - periodic refresh for Kyiv and Kyiv region schedules + - blocked window: `23:55-00:05` (`Europe/Kyiv`) + - midnight rollover: moves `tomorrow` schedules into `today` ## Configuration -Spring also loads `.env` automatically (`spring.config.import: optional:file:.env[.properties]`). +Spring loads `.env` automatically via: +`spring.config.import: optional:file:.env[.properties]` Required: -- `TELEGRAM_BOT_TOKEN` - Telegram bot token +- `TELEGRAM_BOT_TOKEN`: Telegram bot token Optional: -- `SPRING_DATASOURCE_URL` - overrides DB URL (default: `jdbc:sqlite:src/main/resources/db/app.db`) -- `scheduler.shutdowns.fixed-delay-ms` - schedule polling interval in minutes (default: `10`) -- `TELEGRAM_ADMIN_CHAT_IDS` - comma-separated admin chat IDs allowed to use `/stats_*` commands +- `TELEGRAM_ADMIN_CHAT_IDS`: comma-separated chat IDs allowed to run `/stats_*` +- `SPRING_DATASOURCE_URL`: datasource URL + default: `jdbc:sqlite:src/main/resources/db/app.db` +- `scheduler.shutdowns.fixed-delay-ms`: polling interval value for refresh scheduler + note: despite the name, value is interpreted in **minutes** (`@Scheduled(..., timeUnit = TimeUnit.MINUTES)`) + default: `10` +- `scheduler.shutdowns.time-zone`: scheduler timezone + default: `Europe/Kyiv` + +Database notes: +- Flyway migrations are enabled. +- JPA `ddl-auto` is `none`. ## Run Locally -1. Set token in `.env` or export it: +1. Set env vars (example): ```bash export TELEGRAM_BOT_TOKEN=your_token_here @@ -41,13 +62,13 @@ export TELEGRAM_BOT_TOKEN=your_token_here ./gradlew playwrightInstallChromium ``` -3. Start the app: +3. Start app: ```bash ./gradlew bootRun ``` -The app starts on port `8080`. +App listens on `8080`. ## Build and Test ```bash @@ -60,45 +81,31 @@ The app starts on port `8080`. Script: `scripts/yasno_group_by_address.sh` Purpose: -- Resolve Kyiv outage group by address via YASNO API in 3 steps: - 1. `street` lookup - 2. `house` lookup - 3. `group` lookup +- Resolve outage group by address via YASNO API (`street -> house -> group`). +- Works with default `region_id=25`, `dso_id=902`. Prerequisites: -- `httpie` (`http` command) +- `httpie` (`http`) - `jq` Usage: ```bash ./scripts/yasno_group_by_address.sh 'вул. Богдана Хмельницького, 11' -``` - -Also valid: -```bash ./scripts/yasno_group_by_address.sh 'вул. Богдана Хмельницького 11' -``` - -Optional params: -```bash ./scripts/yasno_group_by_address.sh '
' [region_id] [dso_id] ``` -- Default `region_id=25` -- Default `dso_id=902` Output: -- `stdout`: resolved group id (`group` or `group.subgroup`, for example `29.1`) -- `stderr`: debug info with raw HTTP responses for `streets`, `houses`, and `group`, plus resolved `streetId`/`houseId` +- `stdout`: resolved group id (for example `29.1`) +- `stderr`: debug payloads (`streets`, `houses`, `group`) and resolved IDs ## Docker Build local image: - ```bash docker build -t firefly:local . ``` Run local container: - ```bash mkdir -p ./db touch ./db/app.db @@ -111,8 +118,10 @@ docker run --rm \ firefly:local ``` -Note: `docker-compose.yml` is currently deployment-oriented and references `ghcr.io/ndmik-dev/firefly:latest` with host volume `/opt/firefly/db:/app/db`. +`docker-compose.yml` is deployment-oriented and uses: +- image `ghcr.io/ndmik-dev/firefly:latest` +- DB mount `/opt/firefly/db:/app/db` ## CI/CD -- `.github/workflows/build.yml`: build on pushes to `main` (currently marked as temporary in the file comment). -- `.github/workflows/deploy.yml`: on pushes to `dev`, builds `bootJar`, builds and pushes `ghcr.io/${{ github.repository }}:latest`, then triggers Dokploy deployment. +- `.github/workflows/build.yml`: runs `./gradlew build` on `push` and `pull_request`. +- `.github/workflows/deploy.yml`: on merged PR into `main`, builds `bootJar`, pushes GHCR image, then triggers Dokploy deployment. From 22285a7495ab21fd46f8da66a306fc28ea568c16 Mon Sep 17 00:00:00 2001 From: ndmik <255708774+ndmik-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:42:05 +0200 Subject: [PATCH 3/5] fix unhandled button exception --- .../ndmik/bot/handler/GroupPageHandler.java | 25 ++++++++++++++-- .../ndmik/bot/telegram/DtekShutdownBot.java | 22 +++++++++----- .../bot/telegram/DtekShutdownBotTests.java | 29 +++++++++++++++++++ 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java index e393c1e..da1b2fd 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java @@ -22,9 +22,12 @@ public GroupPageHandler(RegionHandler regionHandler, public void handle(Update update) { long chatId = getChatId(update); String data = update.getCallbackQuery().getData(); - PagePayload payload = parsePayload(data); UserSettings user = userRepository.findByChatId(chatId) .orElseThrow(() -> new RuntimeException(String.format("User not found for chatId=%s", chatId))); + DtekArea fallbackArea = user.getTmpArea() != null + ? user.getTmpArea() + : (user.getArea() != null ? user.getArea() : DtekArea.KYIV_REGION); + PagePayload payload = parsePayload(data, fallbackArea); String selectedGroupId = user.getTmpGroupId() != null ? user.getTmpGroupId() : user.getGroupId(); @@ -41,9 +44,13 @@ public void handle(Update update) { ); } - private PagePayload parsePayload(String data) { + private PagePayload parsePayload(String data, DtekArea fallbackArea) { + if (data.matches("^\\d+/\\d+$")) { + return new PagePayload(fallbackArea, parseLegacyPageIndex(data)); + } + String[] parts = data.split(":"); - DtekArea area = DtekArea.KYIV_REGION; + DtekArea area = fallbackArea; int page = 0; if (parts.length > 1) { @@ -61,6 +68,18 @@ private PagePayload parsePayload(String data) { return new PagePayload(area, Math.max(page, 0)); } + private int parseLegacyPageIndex(String data) { + String[] legacyParts = data.split("/"); + if (legacyParts.length == 0) { + return 0; + } + try { + return Math.max(Integer.parseInt(legacyParts[0]) - 1, 0); + } catch (NumberFormatException ignored) { + return 0; + } + } + private record PagePayload(DtekArea area, int page) { } } diff --git a/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java b/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java index 4916dd6..c6c02c4 100644 --- a/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java +++ b/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java @@ -17,10 +17,12 @@ import ua.ndmik.bot.service.YasnoGroupResolverService; import java.util.Optional; +import java.util.regex.Pattern; @Service @Profile("!test") public class DtekShutdownBot implements SpringLongPollingBot, LongPollingSingleThreadUpdateConsumer { + private static final Pattern LEGACY_PAGE_CALLBACK = Pattern.compile("^\\d+/\\d+$"); private final String botToken; private final TelegramService telegramService; @@ -83,19 +85,25 @@ private void handleMessage(Update update) { private void handleCallback(Update update) { try { String data = update.getCallbackQuery().getData(); - String callbackKey = data.split(":", 2)[0]; - MenuCallback callback; - try { - callback = MenuCallback.valueOf(callbackKey); - } catch (IllegalArgumentException e) { - callback = MenuCallback.DEFAULT; - } + MenuCallback callback = resolveCallback(data); callbackHandlerResolver.getHandler(callback).handle(update); } finally { telegramService.answerCallback(update.getCallbackQuery().getId()); } } + private MenuCallback resolveCallback(String data) { + String callbackKey = data.split(":", 2)[0]; + try { + return MenuCallback.valueOf(callbackKey); + } catch (IllegalArgumentException e) { + if (LEGACY_PAGE_CALLBACK.matcher(data).matches()) { + return MenuCallback.GROUP_PAGE; + } + return MenuCallback.DEFAULT; + } + } + private boolean isCommand(String text, String command) { return text.equals(command) || text.startsWith(command + "@"); } diff --git a/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java b/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java index df4889c..465d039 100644 --- a/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java +++ b/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java @@ -2,9 +2,12 @@ import org.junit.jupiter.api.Test; import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.CallbackQuery; import org.telegram.telegrambots.meta.api.objects.message.Message; +import ua.ndmik.bot.handler.CallbackHandler; import ua.ndmik.bot.handler.CallbackHandlerResolver; import ua.ndmik.bot.model.DtekArea; +import ua.ndmik.bot.model.MenuCallback; import ua.ndmik.bot.model.ResolvedYasnoGroup; import ua.ndmik.bot.model.entity.UserSettings; import ua.ndmik.bot.repository.UserSettingsRepository; @@ -80,6 +83,18 @@ void consume_doesNotResolveAddressWhenUserIsNotAwaitingInput() { then(telegramService).should(never()).sendUpdate(eq(user), anyString()); } + @Test + void consume_routesLegacyPageFractionCallbackToGroupPageHandler() { + Update update = callbackUpdate(777L, "cbq-1", "1/5"); + CallbackHandler groupPageHandler = mock(CallbackHandler.class); + given(callbackHandlerResolver.getHandler(MenuCallback.GROUP_PAGE)).willReturn(groupPageHandler); + + bot.consume(update); + + then(groupPageHandler).should().handle(update); + then(telegramService).should().answerCallback("cbq-1"); + } + private Update messageUpdate(long chatId, String text) { Update update = mock(Update.class); Message message = mock(Message.class); @@ -91,4 +106,18 @@ private Update messageUpdate(long chatId, String text) { given(update.hasCallbackQuery()).willReturn(false); return update; } + + private Update callbackUpdate(long chatId, String callbackId, String callbackData) { + Update update = mock(Update.class); + CallbackQuery callbackQuery = mock(CallbackQuery.class); + Message message = mock(Message.class); + given(update.hasMessage()).willReturn(false); + given(update.hasCallbackQuery()).willReturn(true); + given(update.getCallbackQuery()).willReturn(callbackQuery); + given(callbackQuery.getData()).willReturn(callbackData); + given(callbackQuery.getId()).willReturn(callbackId); + given(callbackQuery.getMessage()).willReturn(message); + given(message.getChatId()).willReturn(chatId); + return update; + } } From 60fa8630f638f4a8a24948950b8ca810e2a70ce7 Mon Sep 17 00:00:00 2001 From: ndmik <255708774+ndmik-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:46:34 +0200 Subject: [PATCH 4/5] group data classes --- src/main/java/ua/ndmik/bot/client/DtekClient.java | 4 ++-- .../java/ua/ndmik/bot/client/DtekCookieProvider.java | 2 +- src/main/java/ua/ndmik/bot/client/YasnoClient.java | 2 +- .../bot/converter/ScheduleResponseConverter.java | 6 +++--- .../ndmik/bot/handler/AbstractAreaGroupHandler.java | 12 ++++++------ .../ndmik/bot/handler/CallbackHandlerResolver.java | 2 +- .../java/ua/ndmik/bot/handler/GroupClickHandler.java | 6 ++---- .../java/ua/ndmik/bot/handler/GroupDoneHandler.java | 4 ++-- .../java/ua/ndmik/bot/handler/GroupPageHandler.java | 6 ++---- .../ua/ndmik/bot/handler/GroupResolvingHandler.java | 4 ++-- .../ua/ndmik/bot/handler/GroupSelectionHandler.java | 4 ++-- src/main/java/ua/ndmik/bot/handler/KyivHandler.java | 2 +- .../ndmik/bot/handler/NotificationsClickHandler.java | 2 +- .../java/ua/ndmik/bot/handler/RegionHandler.java | 2 +- .../ua/ndmik/bot/handler/RegionsBackHandler.java | 2 +- .../ndmik/bot/model/{ => callback}/MenuCallback.java | 2 +- .../ua/ndmik/bot/model/callback/PagePayload.java | 6 ++++++ .../ndmik/bot/model/callback/SelectionPayload.java | 6 ++++++ .../ua/ndmik/bot/model/{ => common}/DtekArea.java | 2 +- .../java/ua/ndmik/bot/model/entity/Schedule.java | 2 +- .../java/ua/ndmik/bot/model/entity/ScheduleId.java | 2 +- .../java/ua/ndmik/bot/model/entity/UserSettings.java | 2 +- .../bot/model/{ => schedule}/GroupSchedule.java | 2 +- .../ua/ndmik/bot/model/{ => schedule}/HourState.java | 2 +- .../bot/model/{ => schedule}/LightInterval.java | 2 +- .../bot/model/{ => schedule}/ScheduleResponse.java | 2 +- .../bot/model/{ => schedule}/ShutdownInterval.java | 2 +- .../ua/ndmik/bot/model/{ => telegram}/Message.java | 2 +- .../ua/ndmik/bot/model/{ => yasno}/AddressItem.java | 2 +- .../bot/model/{ => yasno}/ResolvedYasnoGroup.java | 2 +- .../bot/scheduler/MidnightRolloverScheduler.java | 2 +- .../ua/ndmik/bot/scheduler/ShutdownsScheduler.java | 4 ++-- .../ua/ndmik/bot/service/DtekShutdownsService.java | 4 ++-- .../java/ua/ndmik/bot/service/MessageFormatter.java | 2 +- .../java/ua/ndmik/bot/service/TelegramService.java | 8 ++++---- .../ndmik/bot/service/YasnoGroupResolverService.java | 4 ++-- .../java/ua/ndmik/bot/telegram/DtekShutdownBot.java | 6 +++--- src/main/java/ua/ndmik/bot/util/ScheduleParser.java | 2 +- .../java/ua/ndmik/bot/util/ScheduleStateUtils.java | 2 +- .../bot/handler/AbstractAreaGroupHandlerTests.java | 2 +- .../scheduler/MidnightRolloverSchedulerTests.java | 4 ++-- .../ndmik/bot/service/DtekShutdownsServiceTests.java | 4 ++-- .../ua/ndmik/bot/telegram/DtekShutdownBotTests.java | 6 +++--- 43 files changed, 78 insertions(+), 70 deletions(-) rename src/main/java/ua/ndmik/bot/model/{ => callback}/MenuCallback.java (86%) create mode 100644 src/main/java/ua/ndmik/bot/model/callback/PagePayload.java create mode 100644 src/main/java/ua/ndmik/bot/model/callback/SelectionPayload.java rename src/main/java/ua/ndmik/bot/model/{ => common}/DtekArea.java (91%) rename src/main/java/ua/ndmik/bot/model/{ => schedule}/GroupSchedule.java (91%) rename src/main/java/ua/ndmik/bot/model/{ => schedule}/HourState.java (92%) rename src/main/java/ua/ndmik/bot/model/{ => schedule}/LightInterval.java (64%) rename src/main/java/ua/ndmik/bot/model/{ => schedule}/ScheduleResponse.java (79%) rename src/main/java/ua/ndmik/bot/model/{ => schedule}/ShutdownInterval.java (75%) rename src/main/java/ua/ndmik/bot/model/{ => telegram}/Message.java (85%) rename src/main/java/ua/ndmik/bot/model/{ => yasno}/AddressItem.java (60%) rename src/main/java/ua/ndmik/bot/model/{ => yasno}/ResolvedYasnoGroup.java (85%) diff --git a/src/main/java/ua/ndmik/bot/client/DtekClient.java b/src/main/java/ua/ndmik/bot/client/DtekClient.java index 534fc0f..158a3bb 100644 --- a/src/main/java/ua/ndmik/bot/client/DtekClient.java +++ b/src/main/java/ua/ndmik/bot/client/DtekClient.java @@ -6,8 +6,8 @@ import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; -import ua.ndmik.bot.model.DtekArea; -import ua.ndmik.bot.model.ScheduleResponse; +import ua.ndmik.bot.model.common.DtekArea; +import ua.ndmik.bot.model.schedule.ScheduleResponse; import java.util.Locale; import java.util.Optional; diff --git a/src/main/java/ua/ndmik/bot/client/DtekCookieProvider.java b/src/main/java/ua/ndmik/bot/client/DtekCookieProvider.java index 1acee7f..130f931 100644 --- a/src/main/java/ua/ndmik/bot/client/DtekCookieProvider.java +++ b/src/main/java/ua/ndmik/bot/client/DtekCookieProvider.java @@ -12,7 +12,7 @@ import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import ua.ndmik.bot.model.DtekArea; +import ua.ndmik.bot.model.common.DtekArea; import java.time.Duration; import java.time.Instant; diff --git a/src/main/java/ua/ndmik/bot/client/YasnoClient.java b/src/main/java/ua/ndmik/bot/client/YasnoClient.java index 30daf4a..ef62998 100644 --- a/src/main/java/ua/ndmik/bot/client/YasnoClient.java +++ b/src/main/java/ua/ndmik/bot/client/YasnoClient.java @@ -6,7 +6,7 @@ import org.springframework.web.client.RestClient; import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; -import ua.ndmik.bot.model.AddressItem; +import ua.ndmik.bot.model.yasno.AddressItem; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/ua/ndmik/bot/converter/ScheduleResponseConverter.java b/src/main/java/ua/ndmik/bot/converter/ScheduleResponseConverter.java index 723783e..998563b 100644 --- a/src/main/java/ua/ndmik/bot/converter/ScheduleResponseConverter.java +++ b/src/main/java/ua/ndmik/bot/converter/ScheduleResponseConverter.java @@ -2,9 +2,9 @@ import org.springframework.stereotype.Component; import tools.jackson.databind.json.JsonMapper; -import ua.ndmik.bot.model.DtekArea; -import ua.ndmik.bot.model.GroupSchedule; -import ua.ndmik.bot.model.ScheduleResponse; +import ua.ndmik.bot.model.common.DtekArea; +import ua.ndmik.bot.model.schedule.GroupSchedule; +import ua.ndmik.bot.model.schedule.ScheduleResponse; import ua.ndmik.bot.model.entity.Schedule; import ua.ndmik.bot.model.entity.ScheduleDay; diff --git a/src/main/java/ua/ndmik/bot/handler/AbstractAreaGroupHandler.java b/src/main/java/ua/ndmik/bot/handler/AbstractAreaGroupHandler.java index 3a0ea23..eae2363 100644 --- a/src/main/java/ua/ndmik/bot/handler/AbstractAreaGroupHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/AbstractAreaGroupHandler.java @@ -4,8 +4,8 @@ import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardRow; -import ua.ndmik.bot.model.DtekArea; -import ua.ndmik.bot.model.Message; +import ua.ndmik.bot.model.common.DtekArea; +import ua.ndmik.bot.model.telegram.Message; import ua.ndmik.bot.model.entity.UserSettings; import ua.ndmik.bot.repository.ScheduleRepository; import ua.ndmik.bot.repository.UserSettingsRepository; @@ -14,10 +14,10 @@ import java.util.List; -import static ua.ndmik.bot.model.MenuCallback.GROUP_BACK; -import static ua.ndmik.bot.model.MenuCallback.GROUP_CLICK; -import static ua.ndmik.bot.model.MenuCallback.GROUP_DONE; -import static ua.ndmik.bot.model.MenuCallback.GROUP_PAGE; +import static ua.ndmik.bot.model.callback.MenuCallback.GROUP_BACK; +import static ua.ndmik.bot.model.callback.MenuCallback.GROUP_CLICK; +import static ua.ndmik.bot.model.callback.MenuCallback.GROUP_DONE; +import static ua.ndmik.bot.model.callback.MenuCallback.GROUP_PAGE; public abstract class AbstractAreaGroupHandler implements CallbackHandler { private static final int PAGE_SIZE = 12; diff --git a/src/main/java/ua/ndmik/bot/handler/CallbackHandlerResolver.java b/src/main/java/ua/ndmik/bot/handler/CallbackHandlerResolver.java index 95c1245..cae23b8 100644 --- a/src/main/java/ua/ndmik/bot/handler/CallbackHandlerResolver.java +++ b/src/main/java/ua/ndmik/bot/handler/CallbackHandlerResolver.java @@ -1,7 +1,7 @@ package ua.ndmik.bot.handler; import org.springframework.stereotype.Service; -import ua.ndmik.bot.model.MenuCallback; +import ua.ndmik.bot.model.callback.MenuCallback; import java.util.HashMap; import java.util.Map; diff --git a/src/main/java/ua/ndmik/bot/handler/GroupClickHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupClickHandler.java index d6c5bc5..4901309 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupClickHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupClickHandler.java @@ -2,7 +2,8 @@ import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.objects.Update; -import ua.ndmik.bot.model.DtekArea; +import ua.ndmik.bot.model.callback.SelectionPayload; +import ua.ndmik.bot.model.common.DtekArea; import ua.ndmik.bot.model.entity.UserSettings; import ua.ndmik.bot.repository.UserSettingsRepository; @@ -61,7 +62,4 @@ private SelectionPayload parsePayload(String data) { } return new SelectionPayload(groupId, area, Math.max(page, 0)); } - - private record SelectionPayload(String groupId, DtekArea area, int page) { - } } diff --git a/src/main/java/ua/ndmik/bot/handler/GroupDoneHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupDoneHandler.java index 59e6125..c86f62d 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupDoneHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupDoneHandler.java @@ -2,8 +2,8 @@ import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.objects.Update; -import ua.ndmik.bot.model.Message; -import ua.ndmik.bot.model.DtekArea; +import ua.ndmik.bot.model.telegram.Message; +import ua.ndmik.bot.model.common.DtekArea; import ua.ndmik.bot.model.entity.UserSettings; import ua.ndmik.bot.repository.UserSettingsRepository; import ua.ndmik.bot.service.TelegramService; diff --git a/src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java index da1b2fd..3d7efc3 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java @@ -2,7 +2,8 @@ import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.objects.Update; -import ua.ndmik.bot.model.DtekArea; +import ua.ndmik.bot.model.callback.PagePayload; +import ua.ndmik.bot.model.common.DtekArea; import ua.ndmik.bot.model.entity.UserSettings; import ua.ndmik.bot.repository.UserSettingsRepository; @@ -79,7 +80,4 @@ private int parseLegacyPageIndex(String data) { return 0; } } - - private record PagePayload(DtekArea area, int page) { - } } diff --git a/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java index 35c020f..2638d5d 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java @@ -4,14 +4,14 @@ import org.telegram.telegrambots.meta.api.objects.Update; import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardRow; -import ua.ndmik.bot.model.Message; +import ua.ndmik.bot.model.telegram.Message; import ua.ndmik.bot.model.entity.UserSettings; import ua.ndmik.bot.repository.UserSettingsRepository; import ua.ndmik.bot.service.TelegramService; import java.util.List; -import static ua.ndmik.bot.model.MenuCallback.GROUP_SELECTION; +import static ua.ndmik.bot.model.callback.MenuCallback.GROUP_SELECTION; @Component public class GroupResolvingHandler implements CallbackHandler { diff --git a/src/main/java/ua/ndmik/bot/handler/GroupSelectionHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupSelectionHandler.java index 4527a9d..7840c9e 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupSelectionHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupSelectionHandler.java @@ -4,14 +4,14 @@ import org.telegram.telegrambots.meta.api.objects.Update; import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardRow; -import ua.ndmik.bot.model.Message; +import ua.ndmik.bot.model.telegram.Message; import ua.ndmik.bot.model.entity.UserSettings; import ua.ndmik.bot.repository.UserSettingsRepository; import ua.ndmik.bot.service.TelegramService; import java.util.List; -import static ua.ndmik.bot.model.MenuCallback.*; +import static ua.ndmik.bot.model.callback.MenuCallback.*; @Component public class GroupSelectionHandler implements CallbackHandler { diff --git a/src/main/java/ua/ndmik/bot/handler/KyivHandler.java b/src/main/java/ua/ndmik/bot/handler/KyivHandler.java index 609d3b5..9681d03 100644 --- a/src/main/java/ua/ndmik/bot/handler/KyivHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/KyivHandler.java @@ -1,7 +1,7 @@ package ua.ndmik.bot.handler; import org.springframework.stereotype.Component; -import ua.ndmik.bot.model.DtekArea; +import ua.ndmik.bot.model.common.DtekArea; import ua.ndmik.bot.repository.ScheduleRepository; import ua.ndmik.bot.repository.UserSettingsRepository; import ua.ndmik.bot.service.TelegramService; diff --git a/src/main/java/ua/ndmik/bot/handler/NotificationsClickHandler.java b/src/main/java/ua/ndmik/bot/handler/NotificationsClickHandler.java index 06a3847..0361887 100644 --- a/src/main/java/ua/ndmik/bot/handler/NotificationsClickHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/NotificationsClickHandler.java @@ -2,7 +2,7 @@ import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.objects.Update; -import ua.ndmik.bot.model.Message; +import ua.ndmik.bot.model.telegram.Message; import ua.ndmik.bot.repository.UserSettingsRepository; import ua.ndmik.bot.service.TelegramService; diff --git a/src/main/java/ua/ndmik/bot/handler/RegionHandler.java b/src/main/java/ua/ndmik/bot/handler/RegionHandler.java index de3a268..78db101 100644 --- a/src/main/java/ua/ndmik/bot/handler/RegionHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/RegionHandler.java @@ -1,6 +1,6 @@ package ua.ndmik.bot.handler; -import ua.ndmik.bot.model.DtekArea; +import ua.ndmik.bot.model.common.DtekArea; import ua.ndmik.bot.repository.ScheduleRepository; import ua.ndmik.bot.repository.UserSettingsRepository; import ua.ndmik.bot.service.TelegramService; diff --git a/src/main/java/ua/ndmik/bot/handler/RegionsBackHandler.java b/src/main/java/ua/ndmik/bot/handler/RegionsBackHandler.java index 7cc9f71..644ce96 100644 --- a/src/main/java/ua/ndmik/bot/handler/RegionsBackHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/RegionsBackHandler.java @@ -2,7 +2,7 @@ import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.objects.Update; -import ua.ndmik.bot.model.Message; +import ua.ndmik.bot.model.telegram.Message; import ua.ndmik.bot.model.entity.UserSettings; import ua.ndmik.bot.repository.UserSettingsRepository; import ua.ndmik.bot.service.TelegramService; diff --git a/src/main/java/ua/ndmik/bot/model/MenuCallback.java b/src/main/java/ua/ndmik/bot/model/callback/MenuCallback.java similarity index 86% rename from src/main/java/ua/ndmik/bot/model/MenuCallback.java rename to src/main/java/ua/ndmik/bot/model/callback/MenuCallback.java index 9fdbdb8..6dc2cad 100644 --- a/src/main/java/ua/ndmik/bot/model/MenuCallback.java +++ b/src/main/java/ua/ndmik/bot/model/callback/MenuCallback.java @@ -1,4 +1,4 @@ -package ua.ndmik.bot.model; +package ua.ndmik.bot.model.callback; import lombok.Getter; diff --git a/src/main/java/ua/ndmik/bot/model/callback/PagePayload.java b/src/main/java/ua/ndmik/bot/model/callback/PagePayload.java new file mode 100644 index 0000000..3a3d51d --- /dev/null +++ b/src/main/java/ua/ndmik/bot/model/callback/PagePayload.java @@ -0,0 +1,6 @@ +package ua.ndmik.bot.model.callback; + +import ua.ndmik.bot.model.common.DtekArea; + +public record PagePayload(DtekArea area, int page) { +} diff --git a/src/main/java/ua/ndmik/bot/model/callback/SelectionPayload.java b/src/main/java/ua/ndmik/bot/model/callback/SelectionPayload.java new file mode 100644 index 0000000..2fa98b4 --- /dev/null +++ b/src/main/java/ua/ndmik/bot/model/callback/SelectionPayload.java @@ -0,0 +1,6 @@ +package ua.ndmik.bot.model.callback; + +import ua.ndmik.bot.model.common.DtekArea; + +public record SelectionPayload(String groupId, DtekArea area, int page) { +} diff --git a/src/main/java/ua/ndmik/bot/model/DtekArea.java b/src/main/java/ua/ndmik/bot/model/common/DtekArea.java similarity index 91% rename from src/main/java/ua/ndmik/bot/model/DtekArea.java rename to src/main/java/ua/ndmik/bot/model/common/DtekArea.java index da460ae..b0d6c1d 100644 --- a/src/main/java/ua/ndmik/bot/model/DtekArea.java +++ b/src/main/java/ua/ndmik/bot/model/common/DtekArea.java @@ -1,4 +1,4 @@ -package ua.ndmik.bot.model; +package ua.ndmik.bot.model.common; import static ua.ndmik.bot.util.Constants.*; diff --git a/src/main/java/ua/ndmik/bot/model/entity/Schedule.java b/src/main/java/ua/ndmik/bot/model/entity/Schedule.java index 6a6001d..18824f2 100644 --- a/src/main/java/ua/ndmik/bot/model/entity/Schedule.java +++ b/src/main/java/ua/ndmik/bot/model/entity/Schedule.java @@ -5,7 +5,7 @@ import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; import ua.ndmik.bot.converter.LocalDateTimeSqliteConverter; -import ua.ndmik.bot.model.DtekArea; +import ua.ndmik.bot.model.common.DtekArea; import java.time.LocalDateTime; diff --git a/src/main/java/ua/ndmik/bot/model/entity/ScheduleId.java b/src/main/java/ua/ndmik/bot/model/entity/ScheduleId.java index d794f7b..cf63c03 100644 --- a/src/main/java/ua/ndmik/bot/model/entity/ScheduleId.java +++ b/src/main/java/ua/ndmik/bot/model/entity/ScheduleId.java @@ -5,7 +5,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import ua.ndmik.bot.model.DtekArea; +import ua.ndmik.bot.model.common.DtekArea; import java.io.Serializable; diff --git a/src/main/java/ua/ndmik/bot/model/entity/UserSettings.java b/src/main/java/ua/ndmik/bot/model/entity/UserSettings.java index 341ec88..ed75c02 100644 --- a/src/main/java/ua/ndmik/bot/model/entity/UserSettings.java +++ b/src/main/java/ua/ndmik/bot/model/entity/UserSettings.java @@ -2,7 +2,7 @@ import jakarta.persistence.*; import lombok.*; -import ua.ndmik.bot.model.DtekArea; +import ua.ndmik.bot.model.common.DtekArea; @Entity @Table(name = "user_settings") diff --git a/src/main/java/ua/ndmik/bot/model/GroupSchedule.java b/src/main/java/ua/ndmik/bot/model/schedule/GroupSchedule.java similarity index 91% rename from src/main/java/ua/ndmik/bot/model/GroupSchedule.java rename to src/main/java/ua/ndmik/bot/model/schedule/GroupSchedule.java index 1e6848d..eba6d3f 100644 --- a/src/main/java/ua/ndmik/bot/model/GroupSchedule.java +++ b/src/main/java/ua/ndmik/bot/model/schedule/GroupSchedule.java @@ -1,4 +1,4 @@ -package ua.ndmik.bot.model; +package ua.ndmik.bot.model.schedule; import com.fasterxml.jackson.annotation.JsonAnySetter; import lombok.Getter; diff --git a/src/main/java/ua/ndmik/bot/model/HourState.java b/src/main/java/ua/ndmik/bot/model/schedule/HourState.java similarity index 92% rename from src/main/java/ua/ndmik/bot/model/HourState.java rename to src/main/java/ua/ndmik/bot/model/schedule/HourState.java index 75faf40..c218224 100644 --- a/src/main/java/ua/ndmik/bot/model/HourState.java +++ b/src/main/java/ua/ndmik/bot/model/schedule/HourState.java @@ -1,4 +1,4 @@ -package ua.ndmik.bot.model; +package ua.ndmik.bot.model.schedule; import lombok.Getter; diff --git a/src/main/java/ua/ndmik/bot/model/LightInterval.java b/src/main/java/ua/ndmik/bot/model/schedule/LightInterval.java similarity index 64% rename from src/main/java/ua/ndmik/bot/model/LightInterval.java rename to src/main/java/ua/ndmik/bot/model/schedule/LightInterval.java index 0731da0..8515be6 100644 --- a/src/main/java/ua/ndmik/bot/model/LightInterval.java +++ b/src/main/java/ua/ndmik/bot/model/schedule/LightInterval.java @@ -1,4 +1,4 @@ -package ua.ndmik.bot.model; +package ua.ndmik.bot.model.schedule; public record LightInterval(int startMinutes, int endMinutes) { } diff --git a/src/main/java/ua/ndmik/bot/model/ScheduleResponse.java b/src/main/java/ua/ndmik/bot/model/schedule/ScheduleResponse.java similarity index 79% rename from src/main/java/ua/ndmik/bot/model/ScheduleResponse.java rename to src/main/java/ua/ndmik/bot/model/schedule/ScheduleResponse.java index ae48b1d..4baf215 100644 --- a/src/main/java/ua/ndmik/bot/model/ScheduleResponse.java +++ b/src/main/java/ua/ndmik/bot/model/schedule/ScheduleResponse.java @@ -1,4 +1,4 @@ -package ua.ndmik.bot.model; +package ua.ndmik.bot.model.schedule; import java.util.Map; diff --git a/src/main/java/ua/ndmik/bot/model/ShutdownInterval.java b/src/main/java/ua/ndmik/bot/model/schedule/ShutdownInterval.java similarity index 75% rename from src/main/java/ua/ndmik/bot/model/ShutdownInterval.java rename to src/main/java/ua/ndmik/bot/model/schedule/ShutdownInterval.java index 06121ba..4526338 100644 --- a/src/main/java/ua/ndmik/bot/model/ShutdownInterval.java +++ b/src/main/java/ua/ndmik/bot/model/schedule/ShutdownInterval.java @@ -1,4 +1,4 @@ -package ua.ndmik.bot.model; +package ua.ndmik.bot.model.schedule; import java.time.LocalTime; diff --git a/src/main/java/ua/ndmik/bot/model/Message.java b/src/main/java/ua/ndmik/bot/model/telegram/Message.java similarity index 85% rename from src/main/java/ua/ndmik/bot/model/Message.java rename to src/main/java/ua/ndmik/bot/model/telegram/Message.java index b7ba669..a1bce1b 100644 --- a/src/main/java/ua/ndmik/bot/model/Message.java +++ b/src/main/java/ua/ndmik/bot/model/telegram/Message.java @@ -1,4 +1,4 @@ -package ua.ndmik.bot.model; +package ua.ndmik.bot.model.telegram; import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; diff --git a/src/main/java/ua/ndmik/bot/model/AddressItem.java b/src/main/java/ua/ndmik/bot/model/yasno/AddressItem.java similarity index 60% rename from src/main/java/ua/ndmik/bot/model/AddressItem.java rename to src/main/java/ua/ndmik/bot/model/yasno/AddressItem.java index e50366f..b1de15e 100644 --- a/src/main/java/ua/ndmik/bot/model/AddressItem.java +++ b/src/main/java/ua/ndmik/bot/model/yasno/AddressItem.java @@ -1,4 +1,4 @@ -package ua.ndmik.bot.model; +package ua.ndmik.bot.model.yasno; public record AddressItem(long id, String name) { } diff --git a/src/main/java/ua/ndmik/bot/model/ResolvedYasnoGroup.java b/src/main/java/ua/ndmik/bot/model/yasno/ResolvedYasnoGroup.java similarity index 85% rename from src/main/java/ua/ndmik/bot/model/ResolvedYasnoGroup.java rename to src/main/java/ua/ndmik/bot/model/yasno/ResolvedYasnoGroup.java index 88f3d01..c8b91c4 100644 --- a/src/main/java/ua/ndmik/bot/model/ResolvedYasnoGroup.java +++ b/src/main/java/ua/ndmik/bot/model/yasno/ResolvedYasnoGroup.java @@ -1,4 +1,4 @@ -package ua.ndmik.bot.model; +package ua.ndmik.bot.model.yasno; public record ResolvedYasnoGroup( int regionId, diff --git a/src/main/java/ua/ndmik/bot/scheduler/MidnightRolloverScheduler.java b/src/main/java/ua/ndmik/bot/scheduler/MidnightRolloverScheduler.java index db41d8d..13c37a3 100644 --- a/src/main/java/ua/ndmik/bot/scheduler/MidnightRolloverScheduler.java +++ b/src/main/java/ua/ndmik/bot/scheduler/MidnightRolloverScheduler.java @@ -5,7 +5,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import tools.jackson.databind.json.JsonMapper; -import ua.ndmik.bot.model.HourState; +import ua.ndmik.bot.model.schedule.HourState; import ua.ndmik.bot.model.entity.Schedule; import ua.ndmik.bot.repository.ScheduleRepository; diff --git a/src/main/java/ua/ndmik/bot/scheduler/ShutdownsScheduler.java b/src/main/java/ua/ndmik/bot/scheduler/ShutdownsScheduler.java index b621263..512d911 100644 --- a/src/main/java/ua/ndmik/bot/scheduler/ShutdownsScheduler.java +++ b/src/main/java/ua/ndmik/bot/scheduler/ShutdownsScheduler.java @@ -8,8 +8,8 @@ import tools.jackson.databind.json.JsonMapper; import ua.ndmik.bot.client.DtekClient; import ua.ndmik.bot.converter.ScheduleResponseConverter; -import ua.ndmik.bot.model.DtekArea; -import ua.ndmik.bot.model.ScheduleResponse; +import ua.ndmik.bot.model.common.DtekArea; +import ua.ndmik.bot.model.schedule.ScheduleResponse; import ua.ndmik.bot.model.entity.Schedule; import ua.ndmik.bot.model.entity.UserSettings; import ua.ndmik.bot.repository.ScheduleRepository; diff --git a/src/main/java/ua/ndmik/bot/service/DtekShutdownsService.java b/src/main/java/ua/ndmik/bot/service/DtekShutdownsService.java index e1c28fa..044998c 100644 --- a/src/main/java/ua/ndmik/bot/service/DtekShutdownsService.java +++ b/src/main/java/ua/ndmik/bot/service/DtekShutdownsService.java @@ -4,8 +4,8 @@ import org.springframework.stereotype.Service; import tools.jackson.core.type.TypeReference; import tools.jackson.databind.json.JsonMapper; -import ua.ndmik.bot.model.HourState; -import ua.ndmik.bot.model.ShutdownInterval; +import ua.ndmik.bot.model.schedule.HourState; +import ua.ndmik.bot.model.schedule.ShutdownInterval; import ua.ndmik.bot.model.entity.Schedule; import java.time.LocalDate; diff --git a/src/main/java/ua/ndmik/bot/service/MessageFormatter.java b/src/main/java/ua/ndmik/bot/service/MessageFormatter.java index 960dd30..e07ceea 100644 --- a/src/main/java/ua/ndmik/bot/service/MessageFormatter.java +++ b/src/main/java/ua/ndmik/bot/service/MessageFormatter.java @@ -1,7 +1,7 @@ package ua.ndmik.bot.service; import org.springframework.stereotype.Service; -import ua.ndmik.bot.model.LightInterval; +import ua.ndmik.bot.model.schedule.LightInterval; import java.time.LocalTime; import java.time.LocalDate; diff --git a/src/main/java/ua/ndmik/bot/service/TelegramService.java b/src/main/java/ua/ndmik/bot/service/TelegramService.java index fd19cfc..e7ed2b5 100644 --- a/src/main/java/ua/ndmik/bot/service/TelegramService.java +++ b/src/main/java/ua/ndmik/bot/service/TelegramService.java @@ -15,8 +15,8 @@ import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardRow; import org.telegram.telegrambots.meta.exceptions.TelegramApiException; import org.telegram.telegrambots.meta.generics.TelegramClient; -import ua.ndmik.bot.model.DtekArea; -import ua.ndmik.bot.model.Message; +import ua.ndmik.bot.model.common.DtekArea; +import ua.ndmik.bot.model.telegram.Message; import ua.ndmik.bot.model.entity.Schedule; import ua.ndmik.bot.model.entity.UserSettings; import ua.ndmik.bot.repository.ScheduleRepository; @@ -30,8 +30,8 @@ import java.util.function.Supplier; import java.util.stream.Collectors; -import static ua.ndmik.bot.model.MenuCallback.GROUP_SELECTION; -import static ua.ndmik.bot.model.MenuCallback.NOTIFICATION_CLICK; +import static ua.ndmik.bot.model.callback.MenuCallback.GROUP_SELECTION; +import static ua.ndmik.bot.model.callback.MenuCallback.NOTIFICATION_CLICK; @Service @Slf4j diff --git a/src/main/java/ua/ndmik/bot/service/YasnoGroupResolverService.java b/src/main/java/ua/ndmik/bot/service/YasnoGroupResolverService.java index 137f216..72d5152 100644 --- a/src/main/java/ua/ndmik/bot/service/YasnoGroupResolverService.java +++ b/src/main/java/ua/ndmik/bot/service/YasnoGroupResolverService.java @@ -2,8 +2,8 @@ import org.springframework.stereotype.Service; import ua.ndmik.bot.client.YasnoClient; -import ua.ndmik.bot.model.AddressItem; -import ua.ndmik.bot.model.ResolvedYasnoGroup; +import ua.ndmik.bot.model.yasno.AddressItem; +import ua.ndmik.bot.model.yasno.ResolvedYasnoGroup; import ua.ndmik.bot.util.AddressQueryParser; import java.util.List; diff --git a/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java b/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java index c6c02c4..0ffced7 100644 --- a/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java +++ b/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java @@ -8,9 +8,9 @@ import org.telegram.telegrambots.longpolling.util.LongPollingSingleThreadUpdateConsumer; import org.telegram.telegrambots.meta.api.objects.Update; import ua.ndmik.bot.handler.CallbackHandlerResolver; -import ua.ndmik.bot.model.DtekArea; -import ua.ndmik.bot.model.MenuCallback; -import ua.ndmik.bot.model.ResolvedYasnoGroup; +import ua.ndmik.bot.model.common.DtekArea; +import ua.ndmik.bot.model.callback.MenuCallback; +import ua.ndmik.bot.model.yasno.ResolvedYasnoGroup; import ua.ndmik.bot.model.entity.UserSettings; import ua.ndmik.bot.repository.UserSettingsRepository; import ua.ndmik.bot.service.TelegramService; diff --git a/src/main/java/ua/ndmik/bot/util/ScheduleParser.java b/src/main/java/ua/ndmik/bot/util/ScheduleParser.java index 437e686..a8047bd 100644 --- a/src/main/java/ua/ndmik/bot/util/ScheduleParser.java +++ b/src/main/java/ua/ndmik/bot/util/ScheduleParser.java @@ -3,7 +3,7 @@ import org.jsoup.Jsoup; import org.jsoup.nodes.Element; import tools.jackson.databind.json.JsonMapper; -import ua.ndmik.bot.model.ScheduleResponse; +import ua.ndmik.bot.model.schedule.ScheduleResponse; public class ScheduleParser { diff --git a/src/main/java/ua/ndmik/bot/util/ScheduleStateUtils.java b/src/main/java/ua/ndmik/bot/util/ScheduleStateUtils.java index 2cbb37e..6d5a267 100644 --- a/src/main/java/ua/ndmik/bot/util/ScheduleStateUtils.java +++ b/src/main/java/ua/ndmik/bot/util/ScheduleStateUtils.java @@ -1,6 +1,6 @@ package ua.ndmik.bot.util; -import ua.ndmik.bot.model.HourState; +import ua.ndmik.bot.model.schedule.HourState; import java.util.Map; diff --git a/src/test/java/ua/ndmik/bot/handler/AbstractAreaGroupHandlerTests.java b/src/test/java/ua/ndmik/bot/handler/AbstractAreaGroupHandlerTests.java index c495428..de7a1e4 100644 --- a/src/test/java/ua/ndmik/bot/handler/AbstractAreaGroupHandlerTests.java +++ b/src/test/java/ua/ndmik/bot/handler/AbstractAreaGroupHandlerTests.java @@ -1,7 +1,7 @@ package ua.ndmik.bot.handler; import org.junit.jupiter.api.Test; -import ua.ndmik.bot.model.DtekArea; +import ua.ndmik.bot.model.common.DtekArea; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/ua/ndmik/bot/scheduler/MidnightRolloverSchedulerTests.java b/src/test/java/ua/ndmik/bot/scheduler/MidnightRolloverSchedulerTests.java index 44072f1..9372899 100644 --- a/src/test/java/ua/ndmik/bot/scheduler/MidnightRolloverSchedulerTests.java +++ b/src/test/java/ua/ndmik/bot/scheduler/MidnightRolloverSchedulerTests.java @@ -7,8 +7,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import tools.jackson.databind.json.JsonMapper; -import ua.ndmik.bot.model.DtekArea; -import ua.ndmik.bot.model.HourState; +import ua.ndmik.bot.model.common.DtekArea; +import ua.ndmik.bot.model.schedule.HourState; import ua.ndmik.bot.model.entity.Schedule; import ua.ndmik.bot.model.entity.ScheduleDay; import ua.ndmik.bot.repository.ScheduleRepository; diff --git a/src/test/java/ua/ndmik/bot/service/DtekShutdownsServiceTests.java b/src/test/java/ua/ndmik/bot/service/DtekShutdownsServiceTests.java index d0eeb7e..604110e 100644 --- a/src/test/java/ua/ndmik/bot/service/DtekShutdownsServiceTests.java +++ b/src/test/java/ua/ndmik/bot/service/DtekShutdownsServiceTests.java @@ -2,8 +2,8 @@ import org.junit.jupiter.api.Test; import tools.jackson.databind.json.JsonMapper; -import ua.ndmik.bot.model.DtekArea; -import ua.ndmik.bot.model.HourState; +import ua.ndmik.bot.model.common.DtekArea; +import ua.ndmik.bot.model.schedule.HourState; import ua.ndmik.bot.model.entity.Schedule; import ua.ndmik.bot.model.entity.ScheduleDay; diff --git a/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java b/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java index 465d039..8b872bc 100644 --- a/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java +++ b/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java @@ -6,9 +6,9 @@ import org.telegram.telegrambots.meta.api.objects.message.Message; import ua.ndmik.bot.handler.CallbackHandler; import ua.ndmik.bot.handler.CallbackHandlerResolver; -import ua.ndmik.bot.model.DtekArea; -import ua.ndmik.bot.model.MenuCallback; -import ua.ndmik.bot.model.ResolvedYasnoGroup; +import ua.ndmik.bot.model.common.DtekArea; +import ua.ndmik.bot.model.callback.MenuCallback; +import ua.ndmik.bot.model.yasno.ResolvedYasnoGroup; import ua.ndmik.bot.model.entity.UserSettings; import ua.ndmik.bot.repository.UserSettingsRepository; import ua.ndmik.bot.service.TelegramService; From 8ecc7cd293bcaad1702a285d4b2368276e0186ed Mon Sep 17 00:00:00 2001 From: ndmik <255708774+ndmik-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:54:26 +0200 Subject: [PATCH 5/5] refactor exception handling --- .../java/ua/ndmik/bot/config/AppConfig.java | 15 +++++- .../ApplicationExceptionReporter.java | 13 +++++ .../ua/ndmik/bot/exception/ErrorResponse.java | 11 +++++ .../bot/exception/GlobalExceptionHandler.java | 48 +++++++++++++++++++ .../bot/exception/UserNotFoundException.java | 8 ++++ .../bot/handler/AbstractAreaGroupHandler.java | 3 +- .../ndmik/bot/handler/GroupBackHandler.java | 3 +- .../ndmik/bot/handler/GroupClickHandler.java | 3 +- .../ndmik/bot/handler/GroupDoneHandler.java | 3 +- .../ndmik/bot/handler/GroupPageHandler.java | 3 +- .../bot/handler/GroupResolvingHandler.java | 3 +- .../ndmik/bot/handler/RegionsBackHandler.java | 3 +- .../ndmik/bot/telegram/DtekShutdownBot.java | 20 +++++--- .../bot/telegram/DtekShutdownBotTests.java | 18 +++++++ 14 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 src/main/java/ua/ndmik/bot/exception/ApplicationExceptionReporter.java create mode 100644 src/main/java/ua/ndmik/bot/exception/ErrorResponse.java create mode 100644 src/main/java/ua/ndmik/bot/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/ua/ndmik/bot/exception/UserNotFoundException.java diff --git a/src/main/java/ua/ndmik/bot/config/AppConfig.java b/src/main/java/ua/ndmik/bot/config/AppConfig.java index d81a6a1..3005d55 100644 --- a/src/main/java/ua/ndmik/bot/config/AppConfig.java +++ b/src/main/java/ua/ndmik/bot/config/AppConfig.java @@ -3,10 +3,11 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; +import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.web.client.RestClient; - -import static ua.ndmik.bot.util.Constants.DTEK_KREM_URL; +import ua.ndmik.bot.exception.ApplicationExceptionReporter; @Configuration @EnableScheduling @@ -40,4 +41,14 @@ public RestClient yasnoRestClient() { .defaultHeader(HttpHeaders.ACCEPT, "application/json") .build(); } + + @Bean + public TaskScheduler taskScheduler(ApplicationExceptionReporter exceptionReporter) { + ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); + taskScheduler.setPoolSize(2); + taskScheduler.setThreadNamePrefix("firefly-scheduler-"); + taskScheduler.setErrorHandler(throwable -> exceptionReporter.report("scheduled task execution", throwable)); + taskScheduler.initialize(); + return taskScheduler; + } } diff --git a/src/main/java/ua/ndmik/bot/exception/ApplicationExceptionReporter.java b/src/main/java/ua/ndmik/bot/exception/ApplicationExceptionReporter.java new file mode 100644 index 0000000..88c10e2 --- /dev/null +++ b/src/main/java/ua/ndmik/bot/exception/ApplicationExceptionReporter.java @@ -0,0 +1,13 @@ +package ua.ndmik.bot.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class ApplicationExceptionReporter { + + public void report(String context, Throwable throwable) { + log.error("Unhandled exception in {}", context, throwable); + } +} diff --git a/src/main/java/ua/ndmik/bot/exception/ErrorResponse.java b/src/main/java/ua/ndmik/bot/exception/ErrorResponse.java new file mode 100644 index 0000000..5d2d593 --- /dev/null +++ b/src/main/java/ua/ndmik/bot/exception/ErrorResponse.java @@ -0,0 +1,11 @@ +package ua.ndmik.bot.exception; + +import java.time.Instant; + +public record ErrorResponse( + String code, + String message, + String path, + Instant timestamp +) { +} diff --git a/src/main/java/ua/ndmik/bot/exception/GlobalExceptionHandler.java b/src/main/java/ua/ndmik/bot/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..7797f06 --- /dev/null +++ b/src/main/java/ua/ndmik/bot/exception/GlobalExceptionHandler.java @@ -0,0 +1,48 @@ +package ua.ndmik.bot.exception; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.Instant; + +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity handleUserNotFound(UserNotFoundException ex, + HttpServletRequest request) { + log.warn("User settings not found for path={}", request.getRequestURI(), ex); + return buildResponse("USER_SETTINGS_NOT_FOUND", ex.getMessage(), request, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex, + HttpServletRequest request) { + log.warn("Illegal argument for path={}", request.getRequestURI(), ex); + return buildResponse("ILLEGAL_ARGUMENT", ex.getMessage(), request, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnhandledException(Exception ex, HttpServletRequest request) { + log.error("Unhandled exception for path={}", request.getRequestURI(), ex); + return buildResponse( + "INTERNAL_ERROR", + "Internal server error", + request, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + + private ResponseEntity buildResponse(String code, + String message, + HttpServletRequest request, + HttpStatus status) { + return ResponseEntity.status(status) + .body(new ErrorResponse(code, message, request.getRequestURI(), Instant.now())); + } +} diff --git a/src/main/java/ua/ndmik/bot/exception/UserNotFoundException.java b/src/main/java/ua/ndmik/bot/exception/UserNotFoundException.java new file mode 100644 index 0000000..aeeceb6 --- /dev/null +++ b/src/main/java/ua/ndmik/bot/exception/UserNotFoundException.java @@ -0,0 +1,8 @@ +package ua.ndmik.bot.exception; + +public class UserNotFoundException extends RuntimeException { + + public UserNotFoundException(long chatId) { + super("User not found for chatId=" + chatId); + } +} diff --git a/src/main/java/ua/ndmik/bot/handler/AbstractAreaGroupHandler.java b/src/main/java/ua/ndmik/bot/handler/AbstractAreaGroupHandler.java index eae2363..0e744bd 100644 --- a/src/main/java/ua/ndmik/bot/handler/AbstractAreaGroupHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/AbstractAreaGroupHandler.java @@ -4,6 +4,7 @@ import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardRow; +import ua.ndmik.bot.exception.UserNotFoundException; import ua.ndmik.bot.model.common.DtekArea; import ua.ndmik.bot.model.telegram.Message; import ua.ndmik.bot.model.entity.UserSettings; @@ -130,6 +131,6 @@ static boolean isSelectedForArea(String groupId, String selectedGroupId, DtekAre private UserSettings requireUser(long chatId) { return userRepository.findByChatId(chatId) - .orElseThrow(() -> new RuntimeException(String.format("User not found for chatId=%s", chatId))); + .orElseThrow(() -> new UserNotFoundException(chatId)); } } diff --git a/src/main/java/ua/ndmik/bot/handler/GroupBackHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupBackHandler.java index cc3d707..3e7d660 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupBackHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupBackHandler.java @@ -2,6 +2,7 @@ import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.objects.Update; +import ua.ndmik.bot.exception.UserNotFoundException; import ua.ndmik.bot.model.entity.UserSettings; import ua.ndmik.bot.repository.UserSettingsRepository; @@ -21,7 +22,7 @@ public GroupBackHandler(GroupSelectionHandler groupSelectionHandler, public void handle(Update update) { long chatId = getChatId(update); UserSettings user = userRepository.findByChatId(chatId) - .orElseThrow(() -> new RuntimeException(String.format("User not found for chatId=%s", chatId))); + .orElseThrow(() -> new UserNotFoundException(chatId)); user.setTmpGroupId(null); user.setAwaitingAddressInput(false); userRepository.save(user); diff --git a/src/main/java/ua/ndmik/bot/handler/GroupClickHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupClickHandler.java index 4901309..45b3969 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupClickHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupClickHandler.java @@ -2,6 +2,7 @@ import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.objects.Update; +import ua.ndmik.bot.exception.UserNotFoundException; import ua.ndmik.bot.model.callback.SelectionPayload; import ua.ndmik.bot.model.common.DtekArea; import ua.ndmik.bot.model.entity.UserSettings; @@ -25,7 +26,7 @@ public void handle(Update update) { String data = update.getCallbackQuery().getData(); SelectionPayload payload = parsePayload(data); UserSettings user = userRepository.findByChatId(chatId) - .orElseThrow(() -> new RuntimeException(String.format("User not found for chatId=%s", chatId))); + .orElseThrow(() -> new UserNotFoundException(chatId)); user.setTmpGroupId(payload.groupId()); user.setTmpArea(payload.area()); user.setAwaitingAddressInput(false); diff --git a/src/main/java/ua/ndmik/bot/handler/GroupDoneHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupDoneHandler.java index c86f62d..fcc6d42 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupDoneHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupDoneHandler.java @@ -2,6 +2,7 @@ import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.objects.Update; +import ua.ndmik.bot.exception.UserNotFoundException; import ua.ndmik.bot.model.telegram.Message; import ua.ndmik.bot.model.common.DtekArea; import ua.ndmik.bot.model.entity.UserSettings; @@ -27,7 +28,7 @@ public GroupDoneHandler(TelegramService telegramService, public void handle(Update update) { long chatId = getChatId(update); UserSettings user = userRepository.findByChatId(chatId) - .orElseThrow(() -> new RuntimeException(String.format("User not found for chatId=%s", chatId))); + .orElseThrow(() -> new UserNotFoundException(chatId)); String groupId = user.getTmpGroupId(); DtekArea area = user.getTmpArea(); if (groupId == null) { diff --git a/src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java index 3d7efc3..7ed0f00 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java @@ -2,6 +2,7 @@ import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.objects.Update; +import ua.ndmik.bot.exception.UserNotFoundException; import ua.ndmik.bot.model.callback.PagePayload; import ua.ndmik.bot.model.common.DtekArea; import ua.ndmik.bot.model.entity.UserSettings; @@ -24,7 +25,7 @@ public void handle(Update update) { long chatId = getChatId(update); String data = update.getCallbackQuery().getData(); UserSettings user = userRepository.findByChatId(chatId) - .orElseThrow(() -> new RuntimeException(String.format("User not found for chatId=%s", chatId))); + .orElseThrow(() -> new UserNotFoundException(chatId)); DtekArea fallbackArea = user.getTmpArea() != null ? user.getTmpArea() : (user.getArea() != null ? user.getArea() : DtekArea.KYIV_REGION); diff --git a/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java index 2638d5d..3126e8c 100644 --- a/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java @@ -4,6 +4,7 @@ import org.telegram.telegrambots.meta.api.objects.Update; import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardRow; +import ua.ndmik.bot.exception.UserNotFoundException; import ua.ndmik.bot.model.telegram.Message; import ua.ndmik.bot.model.entity.UserSettings; import ua.ndmik.bot.repository.UserSettingsRepository; @@ -29,7 +30,7 @@ public GroupResolvingHandler(UserSettingsRepository userRepository, public void handle(Update update) { long chatId = getChatId(update); UserSettings user = userRepository.findByChatId(chatId) - .orElseThrow(() -> new RuntimeException(String.format("User not found for chatId=%s", chatId))); + .orElseThrow(() -> new UserNotFoundException(chatId)); user.setAwaitingAddressInput(true); userRepository.save(user); diff --git a/src/main/java/ua/ndmik/bot/handler/RegionsBackHandler.java b/src/main/java/ua/ndmik/bot/handler/RegionsBackHandler.java index 644ce96..01846ca 100644 --- a/src/main/java/ua/ndmik/bot/handler/RegionsBackHandler.java +++ b/src/main/java/ua/ndmik/bot/handler/RegionsBackHandler.java @@ -2,6 +2,7 @@ import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.objects.Update; +import ua.ndmik.bot.exception.UserNotFoundException; import ua.ndmik.bot.model.telegram.Message; import ua.ndmik.bot.model.entity.UserSettings; import ua.ndmik.bot.repository.UserSettingsRepository; @@ -25,7 +26,7 @@ public RegionsBackHandler(TelegramService telegramService, public void handle(Update update) { long chatId = getChatId(update); UserSettings user = userRepository.findByChatId(chatId) - .orElseThrow(() -> new RuntimeException(String.format("User not found for chatId=%s", chatId))); + .orElseThrow(() -> new UserNotFoundException(chatId)); if (user.isAwaitingAddressInput()) { user.setAwaitingAddressInput(false); userRepository.save(user); diff --git a/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java b/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java index 0ffced7..5021269 100644 --- a/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java +++ b/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java @@ -7,6 +7,7 @@ import org.telegram.telegrambots.longpolling.starter.SpringLongPollingBot; import org.telegram.telegrambots.longpolling.util.LongPollingSingleThreadUpdateConsumer; import org.telegram.telegrambots.meta.api.objects.Update; +import ua.ndmik.bot.exception.ApplicationExceptionReporter; import ua.ndmik.bot.handler.CallbackHandlerResolver; import ua.ndmik.bot.model.common.DtekArea; import ua.ndmik.bot.model.callback.MenuCallback; @@ -26,17 +27,20 @@ public class DtekShutdownBot implements SpringLongPollingBot, LongPollingSingleT private final String botToken; private final TelegramService telegramService; + private final ApplicationExceptionReporter exceptionReporter; private final CallbackHandlerResolver callbackHandlerResolver; private final UserSettingsRepository userRepository; private final YasnoGroupResolverService yasnoGroupResolverService; public DtekShutdownBot(@Value("${telegram.bot-token}") String botToken, TelegramService telegramService, + ApplicationExceptionReporter exceptionReporter, CallbackHandlerResolver callbackHandlerResolver, UserSettingsRepository userRepository, YasnoGroupResolverService yasnoGroupResolverService) { this.botToken = botToken; this.telegramService = telegramService; + this.exceptionReporter = exceptionReporter; this.callbackHandlerResolver = callbackHandlerResolver; this.userRepository = userRepository; this.yasnoGroupResolverService = yasnoGroupResolverService; @@ -54,13 +58,17 @@ public LongPollingUpdateConsumer getUpdatesConsumer() { @Override public void consume(Update update) { - if (update.hasMessage() && update.getMessage().hasText()) { - handleMessage(update); - return; - } + try { + if (update.hasMessage() && update.getMessage().hasText()) { + handleMessage(update); + return; + } - if (update.hasCallbackQuery()) { - handleCallback(update); + if (update.hasCallbackQuery()) { + handleCallback(update); + } + } catch (RuntimeException ex) { + exceptionReporter.report("telegram update processing", ex); } } diff --git a/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java b/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java index 8b872bc..0ef0fff 100644 --- a/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java +++ b/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java @@ -4,6 +4,7 @@ import org.telegram.telegrambots.meta.api.objects.Update; import org.telegram.telegrambots.meta.api.objects.CallbackQuery; import org.telegram.telegrambots.meta.api.objects.message.Message; +import ua.ndmik.bot.exception.ApplicationExceptionReporter; import ua.ndmik.bot.handler.CallbackHandler; import ua.ndmik.bot.handler.CallbackHandlerResolver; import ua.ndmik.bot.model.common.DtekArea; @@ -20,20 +21,24 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; class DtekShutdownBotTests { private final TelegramService telegramService = mock(TelegramService.class); + private final ApplicationExceptionReporter exceptionReporter = mock(ApplicationExceptionReporter.class); private final CallbackHandlerResolver callbackHandlerResolver = mock(CallbackHandlerResolver.class); private final UserSettingsRepository userRepository = mock(UserSettingsRepository.class); private final YasnoGroupResolverService yasnoGroupResolverService = mock(YasnoGroupResolverService.class); private final DtekShutdownBot bot = new DtekShutdownBot( "TEST_TOKEN", telegramService, + exceptionReporter, callbackHandlerResolver, userRepository, yasnoGroupResolverService @@ -95,6 +100,19 @@ void consume_routesLegacyPageFractionCallbackToGroupPageHandler() { then(telegramService).should().answerCallback("cbq-1"); } + @Test + void consume_reportsUnhandledExceptionFromCallbackHandler() { + Update update = callbackUpdate(888L, "cbq-2", "GROUP_PAGE:KYIV:0"); + CallbackHandler callbackHandler = mock(CallbackHandler.class); + given(callbackHandlerResolver.getHandler(MenuCallback.GROUP_PAGE)).willReturn(callbackHandler); + doThrow(new RuntimeException("boom")).when(callbackHandler).handle(update); + + bot.consume(update); + + then(exceptionReporter).should().report(eq("telegram update processing"), any(RuntimeException.class)); + then(telegramService).should().answerCallback("cbq-2"); + } + private Update messageUpdate(long chatId, String text) { Update update = mock(Update.class); Message message = mock(Message.class);