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 {