Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 49 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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 '<address>' [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
Expand All @@ -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.
4 changes: 2 additions & 2 deletions src/main/java/ua/ndmik/bot/client/DtekClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/ua/ndmik/bot/client/DtekCookieProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/ua/ndmik/bot/client/YasnoClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 13 additions & 2 deletions src/main/java/ua/ndmik/bot/config/AppConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
11 changes: 11 additions & 0 deletions src/main/java/ua/ndmik/bot/exception/ErrorResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package ua.ndmik.bot.exception;

import java.time.Instant;

public record ErrorResponse(
String code,
String message,
String path,
Instant timestamp
) {
}
48 changes: 48 additions & 0 deletions src/main/java/ua/ndmik/bot/exception/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> buildResponse(String code,
String message,
HttpServletRequest request,
HttpStatus status) {
return ResponseEntity.status(status)
.body(new ErrorResponse(code, message, request.getRequestURI(), Instant.now()));
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
15 changes: 8 additions & 7 deletions src/main/java/ua/ndmik/bot/handler/AbstractAreaGroupHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/ua/ndmik/bot/handler/GroupBackHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
}
Expand Down
Loading