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.
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/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/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/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 3a0ea23..0e744bd 100644
--- a/src/main/java/ua/ndmik/bot/handler/AbstractAreaGroupHandler.java
+++ b/src/main/java/ua/ndmik/bot/handler/AbstractAreaGroupHandler.java
@@ -4,8 +4,9 @@
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.exception.UserNotFoundException;
+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 +15,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;
@@ -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/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/GroupBackHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupBackHandler.java
index b3c9843..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,8 +22,9 @@ 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);
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..45b3969 100644
--- a/src/main/java/ua/ndmik/bot/handler/GroupClickHandler.java
+++ b/src/main/java/ua/ndmik/bot/handler/GroupClickHandler.java
@@ -2,7 +2,9 @@
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.meta.api.objects.Update;
-import ua.ndmik.bot.model.DtekArea;
+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;
import ua.ndmik.bot.repository.UserSettingsRepository;
@@ -24,9 +26,10 @@ 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);
userRepository.save(user);
regionHandler.reprint(
update,
@@ -60,7 +63,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 437197e..fcc6d42 100644
--- a/src/main/java/ua/ndmik/bot/handler/GroupDoneHandler.java
+++ b/src/main/java/ua/ndmik/bot/handler/GroupDoneHandler.java
@@ -2,8 +2,9 @@
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.exception.UserNotFoundException;
+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;
@@ -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) {
@@ -36,6 +37,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/GroupPageHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java
index e393c1e..7ed0f00 100644
--- a/src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java
+++ b/src/main/java/ua/ndmik/bot/handler/GroupPageHandler.java
@@ -2,7 +2,9 @@
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.meta.api.objects.Update;
-import ua.ndmik.bot.model.DtekArea;
+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;
import ua.ndmik.bot.repository.UserSettingsRepository;
@@ -22,9 +24,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)));
+ .orElseThrow(() -> new UserNotFoundException(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 +46,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 +70,15 @@ private PagePayload parsePayload(String data) {
return new PagePayload(area, Math.max(page, 0));
}
- private record PagePayload(DtekArea area, int page) {
+ 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;
+ }
}
}
diff --git a/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java b/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java
index d04c979..3126e8c 100644
--- a/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java
+++ b/src/main/java/ua/ndmik/bot/handler/GroupResolvingHandler.java
@@ -1,15 +1,62 @@
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.exception.UserNotFoundException;
+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.callback.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 UserNotFoundException(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..7840c9e 100644
--- a/src/main/java/ua/ndmik/bot/handler/GroupSelectionHandler.java
+++ b/src/main/java/ua/ndmik/bot/handler/GroupSelectionHandler.java
@@ -4,36 +4,47 @@
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 {
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/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 149c22f..01846ca 100644
--- a/src/main/java/ua/ndmik/bot/handler/RegionsBackHandler.java
+++ b/src/main/java/ua/ndmik/bot/handler/RegionsBackHandler.java
@@ -2,7 +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.exception.UserNotFoundException;
+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;
@@ -25,7 +26,11 @@ 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);
+ }
String groupId = user.getGroupId();
int messageId = update.getCallbackQuery().getMessage().getMessageId();
Message message;
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 dc4a0f4..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")
@@ -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/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 2411dc1..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
@@ -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..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,9 @@
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;
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..5021269 100644
--- a/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java
+++ b/src/main/java/ua/ndmik/bot/telegram/DtekShutdownBot.java
@@ -7,24 +7,43 @@
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.MenuCallback;
+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;
+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;
+ 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,
- CallbackHandlerResolver callbackHandlerResolver) {
+ 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;
}
@Override
@@ -39,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);
}
}
@@ -61,26 +84,83 @@ private void handleMessage(Update update) {
}
if (isCommand(text, "/stats_week")) {
telegramService.sendWeeklyStats(update.getMessage().getChatId());
+ return;
}
+
+ handleAddressLookup(update, text);
}
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 + "@");
}
+
+ 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/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/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/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
new file mode 100644
index 0000000..0ef0fff
--- /dev/null
+++ b/src/test/java/ua/ndmik/bot/telegram/DtekShutdownBotTests.java
@@ -0,0 +1,141 @@
+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.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;
+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;
+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.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
+ );
+
+ @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());
+ }
+
+ @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");
+ }
+
+ @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);
+ 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;
+ }
+
+ 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;
+ }
+}
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();
+ }
+}