diff --git a/.github/release-notes/v1.0.0.md b/.github/release-notes/v1.0.0.md new file mode 100644 index 0000000..6b91a48 --- /dev/null +++ b/.github/release-notes/v1.0.0.md @@ -0,0 +1,61 @@ +## Firefly v1.0.0 + +First stable release of **Firefly**: a Spring Boot Telegram bot for DTEK outage schedules (Kyiv + Kyiv region), with SQLite persistence and automated schedule refresh. + +### Highlights +- Telegram bot with `/start` flow and inline menu. +- Manual outage group selection for: + - Kyiv (`🏙️ Київ`) + - Kyiv region (`🏘️ Київщина`) +- Auto-detect group by Kyiv address via YASNO (`📍 Знайти групу за адресою (Київ)`). +- Per-user notification toggle and persisted user settings. +- Admin stats commands: + - `/stats_today` + - `/stats_week` +- Scheduled schedule refresh + midnight rollover logic (`Europe/Kyiv`). + +### Tech Stack +- Java 25 +- Spring Boot 4.0.1 +- SQLite + Spring Data JPA + Flyway +- Telegram Bots API (long polling) +- Playwright + Jsoup + +### Breaking Changes +- None (initial stable release). + +### Install / Run (JAR) +Prerequisite: Java 25 + +```bash +export TELEGRAM_BOT_TOKEN=your_token_here +java -jar firefly-1.0.0.jar +``` + +Optional env vars: +- `TELEGRAM_ADMIN_CHAT_IDS` +- `SPRING_DATASOURCE_URL` (default: `jdbc:sqlite:src/main/resources/db/app.db`) +- `scheduler.shutdowns.fixed-delay-ms` (interpreted as minutes) +- `scheduler.shutdowns.time-zone` (default: `Europe/Kyiv`) + +### Database Notes +- Flyway migrations are enabled. +- `spring.jpa.hibernate.ddl-auto=none`. + +### Checksums +`SHA-256`: + +- `firefly-1.0.0.jar`: `99fdb3b2ef1e34f7231e696416df7f801de9ed2f5a91b652de9c2b0b30c95e0d` + +Verification: +```bash +shasum -a 256 firefly-1.0.0.jar +``` + +### Docker +Also available as container image: +- `ghcr.io/ndmik-dev/firefly:latest` + +--- + +**Full Changelog**: Initial stable release (`v1.0.0`). diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a8d9d47 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [1.0.0] - 2026-03-13 + +### Added +- Initial stable Firefly release. +- Telegram bot `/start` flow with inline menu and persisted user settings. +- Manual outage group selection for Kyiv and Kyiv region. +- YASNO-based address-to-group resolution for Kyiv addresses. +- Per-user notification toggle. +- Admin stats commands: `/stats_today`, `/stats_week`. +- Scheduler refresh for outage schedules and midnight rollover handling. + +### Technical +- Spring Boot 4.0.1, Java 25. +- SQLite persistence with Spring Data JPA and Flyway migrations. +- DTEK/YASNO integrations using Playwright + Jsoup. + diff --git a/Dockerfile b/Dockerfile index 099846a..6e23ca9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +48,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /ms-playwright /ms-playwright -COPY --from=builder /app/build/libs/firefly-0.0.1.jar /app/app.jar +COPY --from=builder /app/build/libs/firefly-1.0.0.jar /app/app.jar ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/README.md b/README.md index 39baedb..2eb8079 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,12 @@ App listens on `8080`. ./gradlew build ``` +## Run Released JAR (`v1.0.0`) +```bash +export TELEGRAM_BOT_TOKEN=your_token_here +java -jar build/libs/firefly-1.0.0.jar +``` + ## YASNO Address Lookup Script Script: `scripts/yasno_group_by_address.sh` diff --git a/build.gradle b/build.gradle index 76cade1..af33e46 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'io.ndmik' -version = '0.0.1' +version = '1.0.0' description = 'firefly' java { diff --git a/src/main/java/ua/ndmik/bot/client/YasnoClient.java b/src/main/java/ua/ndmik/bot/client/YasnoClient.java index 3f20a60..baf3c82 100644 --- a/src/main/java/ua/ndmik/bot/client/YasnoClient.java +++ b/src/main/java/ua/ndmik/bot/client/YasnoClient.java @@ -20,6 +20,7 @@ public class YasnoClient { private static final String STREETS_PATH = "/streets"; private static final String HOUSES_PATH = "/houses"; private static final String GROUP_PATH = "/group"; + public static final String DEFAULT_SUBGROUP = ".1"; private final RestClient restClient; private final JsonMapper mapper; @@ -137,11 +138,21 @@ private String extractGroupId(String json) { log.warn("Unexpected non-JSON YASNO group payload: {}", snippet(json)); return ""; } + String compositeFromRoot = groupWithSubgroup(root); + if (!compositeFromRoot.isBlank()) { + return compositeFromRoot; + } + + String compositeFromData = groupWithSubgroup(root.path("data")); + if (!compositeFromData.isBlank()) { + return compositeFromData; + } + JsonNode candidate = firstPresent( - root.path("group"), root.path("groupId"), - root.path("data").path("group"), - root.path("data").path("groupId") + root.path("group"), + root.path("data").path("groupId"), + root.path("data").path("group") ); if (candidate == null || candidate.isMissingNode() || candidate.isNull()) { @@ -179,6 +190,36 @@ private JsonNode firstPresent(JsonNode... candidates) { return null; } + private String groupWithSubgroup(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return ""; + } + + JsonNode groupNode = firstPresent(node.path("groupId"), node.path("group")); + if (groupNode == null || groupNode.isMissingNode() || groupNode.isNull()) { + return ""; + } + String group = text(groupNode).trim(); + if (group.isBlank()) { + return ""; + } + + JsonNode subgroupNode = firstPresent(node.path("subgroup"), node.path("subGroup")); + if (subgroupNode == null || subgroupNode.isMissingNode() || subgroupNode.isNull()) { + return group.contains(".") ? group : group + DEFAULT_SUBGROUP; + } + + String subgroup = text(subgroupNode).trim(); + if (group.contains(".")) { + return group; + } + if (subgroup.isBlank()) { + return group + DEFAULT_SUBGROUP; + } + + return group + "." + subgroup; + } + private JsonNode firstArray(JsonNode... candidates) { for (JsonNode candidate : candidates) { if (candidate != null && candidate.isArray()) { diff --git a/src/test/java/ua/ndmik/bot/client/YasnoClientTests.java b/src/test/java/ua/ndmik/bot/client/YasnoClientTests.java index b100c17..718a2fe 100644 --- a/src/test/java/ua/ndmik/bot/client/YasnoClientTests.java +++ b/src/test/java/ua/ndmik/bot/client/YasnoClientTests.java @@ -43,12 +43,48 @@ void findStreets_ignoresNonJsonPayload() { } @Test - void findGroup_extractsNumericGroup() { + void findGroup_combinesGroupWithSubgroup() { YasnoClient client = clientWithResponse("{\"group\":20,\"subgroup\":1}"); String group = client.findGroup(25, 902, 1064, 17211); - assertThat(group).isEqualTo("20"); + assertThat(group).isEqualTo("20.1"); + } + + @Test + void findGroup_keepsGroupIdThatAlreadyContainsSubgroup() { + YasnoClient client = clientWithResponse("{\"groupId\":\"29.1\",\"subgroup\":1}"); + + String group = client.findGroup(25, 902, 1064, 17211); + + assertThat(group).isEqualTo("29.1"); + } + + @Test + void findGroup_combinesGroupWithSubgroupFromDataNode() { + YasnoClient client = clientWithResponse("{\"data\":{\"group\":20,\"subgroup\":1}}"); + + String group = client.findGroup(25, 902, 1064, 17211); + + assertThat(group).isEqualTo("20.1"); + } + + @Test + void findGroup_defaultsMissingSubgroupToOne() { + YasnoClient client = clientWithResponse("{\"group\":20}"); + + String group = client.findGroup(25, 902, 1064, 17211); + + assertThat(group).isEqualTo("20.1"); + } + + @Test + void findGroup_defaultsMissingSubgroupToOneInDataNode() { + YasnoClient client = clientWithResponse("{\"data\":{\"group\":29}}"); + + String group = client.findGroup(25, 902, 1064, 17211); + + assertThat(group).isEqualTo("29.1"); } @SuppressWarnings("unchecked")