From 35a97dd5d853503960b635a52ff5f693cc088c77 Mon Sep 17 00:00:00 2001 From: khalil-bot Date: Thu, 28 May 2026 15:28:48 +0200 Subject: [PATCH] feat(notification-service): replace Teams with Telegram notifications Replace Microsoft Teams webhook integration with Telegram Bot API. Remove Teams-specific models (MessageCard, Section, Fact) and service. Add TelegramNotificationService using TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID environment variables. Assisted-by: Claude:claude-sonnet-4-6 --- notification_service/pom.xml | 8 +- .../config/NotificationProperties.java | 18 ++-- .../pi/notification/model/teams/Fact.java | 3 - .../notification/model/teams/MessageCard.java | 30 ------ .../pi/notification/model/teams/Section.java | 14 --- .../scheduler/AirQualityScheduler.java | 14 +-- .../service/TeamsNotificationService.java | 92 ------------------- .../service/TelegramNotificationService.java | 81 ++++++++++++++++ .../src/main/resources/application.yml | 5 +- .../NotificationServiceApplicationTests.java | 3 +- 10 files changed, 111 insertions(+), 157 deletions(-) delete mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Fact.java delete mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/model/teams/MessageCard.java delete mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Section.java delete mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/service/TeamsNotificationService.java create mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/service/TelegramNotificationService.java diff --git a/notification_service/pom.xml b/notification_service/pom.xml index ed52704..1c83f27 100644 --- a/notification_service/pom.xml +++ b/notification_service/pom.xml @@ -15,7 +15,7 @@ notification-service 0.0.1-SNAPSHOT notification-service - Air quality alert notifications via Microsoft Teams + Air quality alert notifications via Telegram 17 @@ -32,6 +32,12 @@ true + + me.paulschwarz + spring-dotenv + 4.0.0 + + org.springframework.boot spring-boot-starter-test diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/config/NotificationProperties.java b/notification_service/src/main/java/ch/hesso/pi/notification/config/NotificationProperties.java index d3840b6..0d1f761 100644 --- a/notification_service/src/main/java/ch/hesso/pi/notification/config/NotificationProperties.java +++ b/notification_service/src/main/java/ch/hesso/pi/notification/config/NotificationProperties.java @@ -7,20 +7,24 @@ import org.springframework.context.annotation.Configuration; @ConfigurationProperties(prefix = "notification") public class NotificationProperties { - private Teams teams = new Teams(); + private Telegram telegram = new Telegram(); private AirQuality airQuality = new AirQuality(); - public Teams getTeams() { return teams; } - public void setTeams(Teams teams) { this.teams = teams; } + public Telegram getTelegram() { return telegram; } + public void setTelegram(Telegram telegram) { this.telegram = telegram; } public AirQuality getAirQuality() { return airQuality; } public void setAirQuality(AirQuality airQuality) { this.airQuality = airQuality; } - public static class Teams { - private String webhookUrl; + public static class Telegram { + private String botToken; + private String chatId; - public String getWebhookUrl() { return webhookUrl; } - public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; } + public String getBotToken() { return botToken; } + public void setBotToken(String botToken) { this.botToken = botToken; } + + public String getChatId() { return chatId; } + public void setChatId(String chatId) { this.chatId = chatId; } } public static class AirQuality { diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Fact.java b/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Fact.java deleted file mode 100644 index b59c3eb..0000000 --- a/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Fact.java +++ /dev/null @@ -1,3 +0,0 @@ -package ch.hesso.pi.notification.model.teams; - -public record Fact(String name, String value) {} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/MessageCard.java b/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/MessageCard.java deleted file mode 100644 index 9fe8ec9..0000000 --- a/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/MessageCard.java +++ /dev/null @@ -1,30 +0,0 @@ -package ch.hesso.pi.notification.model.teams; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.List; - -/** - * Office 365 Connector Card (MessageCard) payload for Teams incoming webhooks. - * Spec: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference - * - * Adaptive Cards require a Bot Framework registration; this format works with - * any incoming webhook URL without extra infrastructure. - */ -public record MessageCard( - - @JsonProperty("@type") - String type, - - @JsonProperty("@context") - String context, - - String themeColor, - String summary, - String title, - String text, - List
sections -) { - public static final String TYPE = "MessageCard"; - public static final String CONTEXT = "https://schema.org/extensions"; -} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Section.java b/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Section.java deleted file mode 100644 index f0d52a0..0000000 --- a/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Section.java +++ /dev/null @@ -1,14 +0,0 @@ -package ch.hesso.pi.notification.model.teams; - -import com.fasterxml.jackson.annotation.JsonInclude; - -import java.util.List; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public record Section( - String activityTitle, - String activitySubtitle, - String activityImage, - List facts, - boolean markdown -) {} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/scheduler/AirQualityScheduler.java b/notification_service/src/main/java/ch/hesso/pi/notification/scheduler/AirQualityScheduler.java index f0a23af..2ff8b47 100644 --- a/notification_service/src/main/java/ch/hesso/pi/notification/scheduler/AirQualityScheduler.java +++ b/notification_service/src/main/java/ch/hesso/pi/notification/scheduler/AirQualityScheduler.java @@ -4,7 +4,7 @@ import ch.hesso.pi.notification.config.NotificationProperties; import ch.hesso.pi.notification.model.Co2Level; import ch.hesso.pi.notification.model.SensorReading; import ch.hesso.pi.notification.service.AirQualityService; -import ch.hesso.pi.notification.service.TeamsNotificationService; +import ch.hesso.pi.notification.service.TelegramNotificationService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -24,15 +24,15 @@ public class AirQualityScheduler { new SensorReading("B1", "Salle B1", 780, 21.0, 42, "closed", Instant.now()) ); - private final AirQualityService airQualityService; - private final TeamsNotificationService teamsService; - private final NotificationProperties props; + private final AirQualityService airQualityService; + private final TelegramNotificationService telegramService; + private final NotificationProperties props; public AirQualityScheduler(AirQualityService airQualityService, - TeamsNotificationService teamsService, + TelegramNotificationService telegramService, NotificationProperties props) { this.airQualityService = airQualityService; - this.teamsService = teamsService; + this.telegramService = telegramService; this.props = props; } @@ -55,7 +55,7 @@ public class AirQualityScheduler { Co2Level level = airQualityService.resolveLevel(reading.co2()); if (airQualityService.isAlertable(level)) { log.info("Alertable reading: room={} co2={} level={}", reading.roomId(), reading.co2(), level); - teamsService.sendAlert(reading, level); + telegramService.sendAlert(reading, level); } } } diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/service/TeamsNotificationService.java b/notification_service/src/main/java/ch/hesso/pi/notification/service/TeamsNotificationService.java deleted file mode 100644 index af0ebec..0000000 --- a/notification_service/src/main/java/ch/hesso/pi/notification/service/TeamsNotificationService.java +++ /dev/null @@ -1,92 +0,0 @@ -package ch.hesso.pi.notification.service; - -import ch.hesso.pi.notification.config.NotificationProperties; -import ch.hesso.pi.notification.model.Co2Level; -import ch.hesso.pi.notification.model.SensorReading; -import ch.hesso.pi.notification.model.teams.Fact; -import ch.hesso.pi.notification.model.teams.MessageCard; -import ch.hesso.pi.notification.model.teams.Section; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; -import org.springframework.web.client.RestClient; -import org.springframework.web.client.RestClientException; - -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.List; - -@Service -public class TeamsNotificationService { - - private static final Logger log = LoggerFactory.getLogger(TeamsNotificationService.class); - private static final DateTimeFormatter TIME_FMT = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()); - - private final RestClient restClient; - private final NotificationProperties props; - - public TeamsNotificationService(RestClient restClient, NotificationProperties props) { - this.restClient = restClient; - this.props = props; - } - - /** - * Builds and sends a MessageCard to the configured Teams webhook. - * Does nothing if the webhook URL is not configured. - */ - public void sendAlert(SensorReading reading, Co2Level level) { - String webhookUrl = props.getTeams().getWebhookUrl(); - if (!StringUtils.hasText(webhookUrl)) { - log.warn("Teams webhook URL not configured — skipping alert for room {}", reading.roomId()); - return; - } - - MessageCard card = buildCard(reading, level); - - try { - restClient.post() - .uri(webhookUrl) - .contentType(MediaType.APPLICATION_JSON) - .body(card) - .retrieve() - .toBodilessEntity(); - - log.info("Alert sent for room {} — {} ({} ppm)", reading.roomId(), level.getLabel(), reading.co2()); - } catch (RestClientException e) { - log.error("Failed to send Teams alert for room {}: {}", reading.roomId(), e.getMessage()); - } - } - - private MessageCard buildCard(SensorReading reading, Co2Level level) { - String timestamp = reading.timestamp() != null - ? TIME_FMT.format(reading.timestamp()) - : "—"; - - Section section = new Section( - level.getLabel() + " — " + reading.roomName(), - "Detected at " + timestamp, - null, - List.of( - new Fact("Room", reading.roomName()), - new Fact("CO₂", reading.co2() + " ppm"), - new Fact("Temperature", reading.temperature() + " °C"), - new Fact("Humidity", reading.humidity() + " %"), - new Fact("Windows", reading.windowState()) - ), - true - ); - - return new MessageCard( - MessageCard.TYPE, - MessageCard.CONTEXT, - level.getThemeColorHex(), - "Air quality alert: " + reading.roomName(), - "🌬️ Air Quality Alert", - null, - List.of(section) - ); - } -} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/service/TelegramNotificationService.java b/notification_service/src/main/java/ch/hesso/pi/notification/service/TelegramNotificationService.java new file mode 100644 index 0000000..7bc258e --- /dev/null +++ b/notification_service/src/main/java/ch/hesso/pi/notification/service/TelegramNotificationService.java @@ -0,0 +1,81 @@ +package ch.hesso.pi.notification.service; + +import ch.hesso.pi.notification.config.NotificationProperties; +import ch.hesso.pi.notification.model.Co2Level; +import ch.hesso.pi.notification.model.SensorReading; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Map; + +@Service +public class TelegramNotificationService { + + private static final Logger log = LoggerFactory.getLogger(TelegramNotificationService.class); + private static final DateTimeFormatter TIME_FMT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()); + + private final RestClient restClient; + private final NotificationProperties props; + + public TelegramNotificationService(RestClient restClient, NotificationProperties props) { + this.restClient = restClient; + this.props = props; + } + + public void sendAlert(SensorReading reading, Co2Level level) { + String token = props.getTelegram().getBotToken(); + String chatId = props.getTelegram().getChatId(); + + if (!StringUtils.hasText(token) || !StringUtils.hasText(chatId)) { + log.warn("Telegram credentials not configured — skipping alert for room {}", reading.roomId()); + return; + } + + String url = "https://api.telegram.org/bot" + token + "/sendMessage"; + String text = buildMessage(reading, level); + + try { + restClient.post() + .uri(url) + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("chat_id", chatId, "text", text, "parse_mode", "HTML")) + .retrieve() + .toBodilessEntity(); + + log.info("Telegram alert sent for room {} — {} ({} ppm)", reading.roomId(), level.getLabel(), reading.co2()); + } catch (RestClientException e) { + log.error("Failed to send Telegram alert for room {}: {}", reading.roomId(), e.getMessage()); + } + } + + private String buildMessage(SensorReading reading, Co2Level level) { + String timestamp = reading.timestamp() != null + ? TIME_FMT.format(reading.timestamp()) + : "—"; + + return String.format( + "%s — Air Quality Alert\n\n" + + "Room: %s\n" + + "CO₂: %d ppm\n" + + "Temperature: %.1f °C\n" + + "Humidity: %d %%\n" + + "Windows: %s\n" + + "Time: %s", + level.getLabel(), + reading.roomName(), + reading.co2(), + reading.temperature(), + reading.humidity(), + reading.windowState(), + timestamp + ); + } +} diff --git a/notification_service/src/main/resources/application.yml b/notification_service/src/main/resources/application.yml index eeccd93..162d42e 100644 --- a/notification_service/src/main/resources/application.yml +++ b/notification_service/src/main/resources/application.yml @@ -3,8 +3,9 @@ spring: name: notification-service notification: - teams: - webhook-url: ${TEAMS_WEBHOOK_URL:https://defaulta372f724c0b24ea0abfb0eb8c6f84e.40.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/e661f41b50314eeebaccc123a0fcc129/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=0L78wU0kY2jjnXUwehf6lkDnA61vQOD4SuTHcRsdOX8} + telegram: + bot-token: ${TELEGRAM_BOT_TOKEN} + chat-id: ${TELEGRAM_CHAT_ID} air-quality: api-url: ${AIR_QUALITY_API_URL:http://localhost:8080} poll-interval-ms: ${POLL_INTERVAL_MS:60000} diff --git a/notification_service/src/test/java/ch/hesso/pi/notification/NotificationServiceApplicationTests.java b/notification_service/src/test/java/ch/hesso/pi/notification/NotificationServiceApplicationTests.java index 468ccb6..482b227 100644 --- a/notification_service/src/test/java/ch/hesso/pi/notification/NotificationServiceApplicationTests.java +++ b/notification_service/src/test/java/ch/hesso/pi/notification/NotificationServiceApplicationTests.java @@ -6,7 +6,8 @@ import org.springframework.test.context.TestPropertySource; @SpringBootTest @TestPropertySource(properties = { - "notification.teams.webhook-url=http://localhost:9999/test", + "notification.telegram.bot-token=test-token", + "notification.telegram.chat-id=123456789", "notification.air-quality.api-url=http://localhost:9998" }) class NotificationServiceApplicationTests {