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
This commit is contained in:
khalil-bot
2026-05-28 15:28:48 +02:00
parent 48a9d2d358
commit 35a97dd5d8
10 changed files with 111 additions and 157 deletions

View File

@@ -15,7 +15,7 @@
<artifactId>notification-service</artifactId> <artifactId>notification-service</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.1-SNAPSHOT</version>
<name>notification-service</name> <name>notification-service</name>
<description>Air quality alert notifications via Microsoft Teams</description> <description>Air quality alert notifications via Telegram</description>
<properties> <properties>
<java.version>17</java.version> <java.version>17</java.version>
@@ -32,6 +32,12 @@
<optional>true</optional> <optional>true</optional>
</dependency> </dependency>
<dependency>
<groupId>me.paulschwarz</groupId>
<artifactId>spring-dotenv</artifactId>
<version>4.0.0</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>

View File

@@ -7,20 +7,24 @@ import org.springframework.context.annotation.Configuration;
@ConfigurationProperties(prefix = "notification") @ConfigurationProperties(prefix = "notification")
public class NotificationProperties { public class NotificationProperties {
private Teams teams = new Teams(); private Telegram telegram = new Telegram();
private AirQuality airQuality = new AirQuality(); private AirQuality airQuality = new AirQuality();
public Teams getTeams() { return teams; } public Telegram getTelegram() { return telegram; }
public void setTeams(Teams teams) { this.teams = teams; } public void setTelegram(Telegram telegram) { this.telegram = telegram; }
public AirQuality getAirQuality() { return airQuality; } public AirQuality getAirQuality() { return airQuality; }
public void setAirQuality(AirQuality airQuality) { this.airQuality = airQuality; } public void setAirQuality(AirQuality airQuality) { this.airQuality = airQuality; }
public static class Teams { public static class Telegram {
private String webhookUrl; private String botToken;
private String chatId;
public String getWebhookUrl() { return webhookUrl; } public String getBotToken() { return botToken; }
public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; } 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 { public static class AirQuality {

View File

@@ -1,3 +0,0 @@
package ch.hesso.pi.notification.model.teams;
public record Fact(String name, String value) {}

View File

@@ -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<Section> sections
) {
public static final String TYPE = "MessageCard";
public static final String CONTEXT = "https://schema.org/extensions";
}

View File

@@ -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<Fact> facts,
boolean markdown
) {}

View File

@@ -4,7 +4,7 @@ import ch.hesso.pi.notification.config.NotificationProperties;
import ch.hesso.pi.notification.model.Co2Level; import ch.hesso.pi.notification.model.Co2Level;
import ch.hesso.pi.notification.model.SensorReading; import ch.hesso.pi.notification.model.SensorReading;
import ch.hesso.pi.notification.service.AirQualityService; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled; 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()) new SensorReading("B1", "Salle B1", 780, 21.0, 42, "closed", Instant.now())
); );
private final AirQualityService airQualityService; private final AirQualityService airQualityService;
private final TeamsNotificationService teamsService; private final TelegramNotificationService telegramService;
private final NotificationProperties props; private final NotificationProperties props;
public AirQualityScheduler(AirQualityService airQualityService, public AirQualityScheduler(AirQualityService airQualityService,
TeamsNotificationService teamsService, TelegramNotificationService telegramService,
NotificationProperties props) { NotificationProperties props) {
this.airQualityService = airQualityService; this.airQualityService = airQualityService;
this.teamsService = teamsService; this.telegramService = telegramService;
this.props = props; this.props = props;
} }
@@ -55,7 +55,7 @@ public class AirQualityScheduler {
Co2Level level = airQualityService.resolveLevel(reading.co2()); Co2Level level = airQualityService.resolveLevel(reading.co2());
if (airQualityService.isAlertable(level)) { if (airQualityService.isAlertable(level)) {
log.info("Alertable reading: room={} co2={} level={}", reading.roomId(), reading.co2(), level); log.info("Alertable reading: room={} co2={} level={}", reading.roomId(), reading.co2(), level);
teamsService.sendAlert(reading, level); telegramService.sendAlert(reading, level);
} }
} }
} }

View File

@@ -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)
);
}
}

View File

@@ -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(
"<b>%s — Air Quality Alert</b>\n\n" +
"<b>Room:</b> %s\n" +
"<b>CO₂:</b> %d ppm\n" +
"<b>Temperature:</b> %.1f °C\n" +
"<b>Humidity:</b> %d %%\n" +
"<b>Windows:</b> %s\n" +
"<b>Time:</b> %s",
level.getLabel(),
reading.roomName(),
reading.co2(),
reading.temperature(),
reading.humidity(),
reading.windowState(),
timestamp
);
}
}

View File

@@ -3,8 +3,9 @@ spring:
name: notification-service name: notification-service
notification: notification:
teams: telegram:
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} bot-token: ${TELEGRAM_BOT_TOKEN}
chat-id: ${TELEGRAM_CHAT_ID}
air-quality: air-quality:
api-url: ${AIR_QUALITY_API_URL:http://localhost:8080} api-url: ${AIR_QUALITY_API_URL:http://localhost:8080}
poll-interval-ms: ${POLL_INTERVAL_MS:60000} poll-interval-ms: ${POLL_INTERVAL_MS:60000}

View File

@@ -6,7 +6,8 @@ import org.springframework.test.context.TestPropertySource;
@SpringBootTest @SpringBootTest
@TestPropertySource(properties = { @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" "notification.air-quality.api-url=http://localhost:9998"
}) })
class NotificationServiceApplicationTests { class NotificationServiceApplicationTests {