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:
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
package ch.hesso.pi.notification.model.teams;
|
|
||||||
|
|
||||||
public record Fact(String name, String value) {}
|
|
||||||
@@ -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";
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
) {}
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user