From 55012b2c4fff7b1ea5c71245e27b69de2433811d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 15 May 2026 15:18:02 +0200 Subject: [PATCH] fix: update Microsoft Teams message structure and theme color handling --- .../server/notify/MicrosoftTeamsNotifier.java | 113 ++++++--- .../notify/MicrosoftTeamsNotifierTest.java | 237 ++++++++++++++---- 2 files changed, 271 insertions(+), 79 deletions(-) diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifier.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifier.java index 597c2659dd4..fdb58e3f2dc 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifier.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifier.java @@ -62,7 +62,7 @@ public class MicrosoftTeamsNotifier extends AbstractStatusChangeNotifier { private static final String SOURCE_KEY = "Source"; - private static final String DEFAULT_THEME_COLOR_EXPRESSION = "#{event.type == 'STATUS_CHANGED' ? (event.statusInfo.status=='UP' ? '6db33f' : 'b32d36') : '439fe0'}"; + private static final String DEFAULT_THEME_COLOR_EXPRESSION = "#{event.type == 'STATUS_CHANGED' ? (event.statusInfo.status=='UP' ? 'Good' : 'Attention') : 'Accent'}"; private static final String DEFAULT_DEREGISTER_ACTIVITY_SUBTITLE_EXPRESSION = "#{instance.registration.name} with id #{instance.id} has de-registered from Spring Boot Admin"; @@ -197,24 +197,44 @@ protected Message getStatusChangedMessage(Instance instance, EvaluationContext c protected Message createMessage(Instance instance, String registeredTitle, String activitySubtitle, EvaluationContext context) { List facts = new ArrayList<>(); - facts.add(new Fact(STATUS_KEY, instance.getStatusInfo().getStatus())); - facts.add(new Fact(SERVICE_URL_KEY, instance.getRegistration().getServiceUrl())); - facts.add(new Fact(HEALTH_URL_KEY, instance.getRegistration().getHealthUrl())); - facts.add(new Fact(MANAGEMENT_URL_KEY, instance.getRegistration().getManagementUrl())); - facts.add(new Fact(SOURCE_KEY, instance.getRegistration().getSource())); - - Section section = Section.builder() - .activityTitle(instance.getRegistration().getName()) - .activitySubtitle(activitySubtitle) - .facts(facts) - .build(); + addFactIfNotNull(facts, STATUS_KEY, instance.getStatusInfo().getStatus()); + addFactIfNotNull(facts, SERVICE_URL_KEY, instance.getRegistration().getServiceUrl()); + addFactIfNotNull(facts, HEALTH_URL_KEY, instance.getRegistration().getHealthUrl()); + addFactIfNotNull(facts, MANAGEMENT_URL_KEY, instance.getRegistration().getManagementUrl()); + addFactIfNotNull(facts, SOURCE_KEY, instance.getRegistration().getSource()); - return Message.builder() - .title(registeredTitle) - .summary(messageSummary) - .themeColor(evaluateExpression(context, themeColor)) - .sections(singletonList(section)) - .build(); + String themeColorValue = evaluateExpression(context, themeColor); + + List cardBody = new ArrayList<>(); + + // Title + cardBody.add(CardElement.builder() + .type("TextBlock") + .text(registeredTitle) + .size("Large") + .weight("Bolder") + .color(themeColorValue) + .build()); + + // Service Name + cardBody.add(CardElement.builder() + .type("TextBlock") + .text(instance.getRegistration().getName()) + .size("Medium") + .weight("Bolder") + .build()); + + // Activity Subtitle + cardBody.add(CardElement.builder().type("TextBlock").text(activitySubtitle).wrap(true).build()); + + // Facts + cardBody.add(CardElement.builder().type("FactSet").facts(facts).build()); + + AdaptiveCard adaptiveCard = AdaptiveCard.builder().body(cardBody).build(); + + Attachment attachment = Attachment.builder().content(adaptiveCard).build(); + + return Message.builder().attachments(singletonList(attachment)).build(); } protected String evaluateExpression(EvaluationContext context, Expression expression) { @@ -232,6 +252,12 @@ protected EvaluationContext createEvaluationContext(InstanceEvent event, Instanc .build(); } + private void addFactIfNotNull(List facts, String title, @Nullable String value) { + if (value != null && !value.isBlank()) { + facts.add(new Fact(title, value)); + } + } + @Nullable public URI getWebhookUrl() { return webhookUrl; } @@ -278,31 +304,62 @@ public void setStatusActivitySubtitle(String statusActivitySubtitle) { @Builder public static class Message { - private final String summary; + private final String type = "message"; - private final String themeColor; + @Builder.Default + private final List attachments = new ArrayList<>(); - private final String title; + } - @Builder.Default - private final List
sections = new ArrayList<>(); + @Data + @Builder + public static class Attachment { + + private final String contentType = "application/vnd.microsoft.card.adaptive"; + + @Nullable private final String contentUrl = null; + + private final AdaptiveCard content; } @Data @Builder - public static class Section { + public static class AdaptiveCard { + + @Builder.Default + private final String schema = "http://adaptivecards.io/schemas/adaptive-card.json"; - private final String activityTitle; + private final String type = "AdaptiveCard"; - private final String activitySubtitle; + private final String version = "1.2"; @Builder.Default - private final List facts = new ArrayList<>(); + private final List body = new ArrayList<>(); + + } + + @Data + @Builder + public static class CardElement { + + private final String type; + + @Nullable private final String text; + + @Nullable private final String size; + + @Nullable private final String weight; + + @Nullable private final String color; + + @Nullable private final Boolean wrap; + + @Nullable private final List facts; } - public record Fact(String name, @Nullable String value) { + public record Fact(String title, @Nullable String value) { } } diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifierTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifierTest.java index 2ce0e862c55..bae1cbb5e98 100644 --- a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifierTest.java +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/notify/MicrosoftTeamsNotifierTest.java @@ -18,6 +18,7 @@ import java.net.URI; +import org.json.JSONObject; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -45,11 +46,11 @@ class MicrosoftTeamsNotifierTest { - private static final String BLUE = "439fe0"; + private static final String ACCENT = "Accent"; - private static final String RED = "b32d36"; + private static final String ATTENTION = "Attention"; - private static final String GREEN = "6db33f"; + private static final String GOOD = "Good"; private static final String APP_NAME = "Test App"; @@ -95,8 +96,8 @@ void test_onClientApplicationDeRegisteredEvent_resolve() { assertThat(entity.getValue().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); assertThat(entity.getValue().getBody()).isNotNull(); - assertMessage(entity.getValue().getBody(), notifier.getDeRegisteredTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId has de-registered from Spring Boot Admin", BLUE); + assertMessage(entity.getValue().getBody(), notifier.getDeRegisteredTitle(), + "Test App with id TestAppId has de-registered from Spring Boot Admin", ACCENT); } @Test @@ -111,8 +112,8 @@ void test_onApplicationRegisteredEvent_resolve() { assertThat(entity.getValue().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); assertThat(entity.getValue().getBody()).isNotNull(); - assertMessage(entity.getValue().getBody(), notifier.getRegisteredTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId has registered with Spring Boot Admin", BLUE); + assertMessage(entity.getValue().getBody(), notifier.getRegisteredTitle(), + "Test App with id TestAppId has registered with Spring Boot Admin", ACCENT); } @Test @@ -127,8 +128,8 @@ void test_onApplicationStatusChangedEvent_resolve() { assertThat(entity.getValue().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); assertThat(entity.getValue().getBody()).isNotNull(); - assertMessage(entity.getValue().getBody(), notifier.getStatusChangedTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId changed status from UNKNOWN to UP", GREEN); + assertMessage(entity.getValue().getBody(), notifier.getStatusChangedTitle(), + "Test App with id TestAppId changed status from UNKNOWN to UP", GOOD); } @Test @@ -148,8 +149,8 @@ void test_getDeregisteredMessageForAppReturns_correctContent() { Message message = notifier.getDeregisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertMessage(message, notifier.getDeRegisteredTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId has de-registered from Spring Boot Admin", BLUE); + assertMessage(message, notifier.getDeRegisteredTitle(), + "Test App with id TestAppId has de-registered from Spring Boot Admin", ACCENT); } @Test @@ -157,8 +158,8 @@ void test_getRegisteredMessageForAppReturns_correctContent() { Message message = notifier.getRegisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertMessage(message, notifier.getRegisteredTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId has registered with Spring Boot Admin", BLUE); + assertMessage(message, notifier.getRegisteredTitle(), + "Test App with id TestAppId has registered with Spring Boot Admin", ACCENT); } @Test @@ -166,8 +167,8 @@ void test_getStatusChangedMessageForAppReturns_correctContent() { Message message = notifier.getStatusChangedMessage(instance, notifier.createEvaluationContext( new InstanceStatusChangedEvent(instance.getId(), 1L, StatusInfo.ofDown()), instance)); - assertMessage(message, notifier.getStatusChangedTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId changed status from UNKNOWN to DOWN", RED); + assertMessage(message, notifier.getStatusChangedTitle(), + "Test App with id TestAppId changed status from UNKNOWN to DOWN", ATTENTION); } @Test @@ -177,8 +178,8 @@ void test_getStatusChangedMessageForAppReturns_UP_to_DOWN() { Message message = notifier.getStatusChangedMessage(instance, notifier.createEvaluationContext( new InstanceStatusChangedEvent(instance.getId(), 1L, StatusInfo.ofDown()), instance)); - assertMessage(message, notifier.getStatusChangedTitle(), notifier.getMessageSummary(), - "Test App with id TestAppId changed status from UP to DOWN", RED); + assertMessage(message, notifier.getStatusChangedTitle(), + "Test App with id TestAppId changed status from UP to DOWN", ATTENTION); } @Test @@ -187,7 +188,7 @@ void test_getStatusChangedMessageWithExtraFormatArgumentReturns_activitySubtitle Message message = notifier.getStatusChangedMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertThat(message.getSections().get(0).getActivitySubtitle()).isEqualTo("STATUS_ACTIVITY_PATTERN_" + APP_NAME); + assertThat(getActivitySubtitleFromMessage(message)).isEqualTo("STATUS_ACTIVITY_PATTERN_" + APP_NAME); } @Test @@ -196,8 +197,7 @@ void test_getRegisterMessageWithExtraFormatArgumentReturns_activitySubtitlePatte Message message = notifier.getRegisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertThat(message.getSections().get(0).getActivitySubtitle()) - .isEqualTo("REGISTER_ACTIVITY_PATTERN_" + APP_NAME); + assertThat(getActivitySubtitleFromMessage(message)).isEqualTo("REGISTER_ACTIVITY_PATTERN_" + APP_NAME); } @Test @@ -206,8 +206,7 @@ void test_getDeRegisterMessageWithExtraFormatArgumentReturns_activitySubtitlePat Message message = notifier.getDeregisteredMessage(instance, notifier.createEvaluationContext(new InstanceDeregisteredEvent(instance.getId(), 1L), instance)); - assertThat(message.getSections().get(0).getActivitySubtitle()) - .isEqualTo("DEREGISTER_ACTIVITY_PATTERN_" + APP_NAME); + assertThat(getActivitySubtitleFromMessage(message)).isEqualTo("DEREGISTER_ACTIVITY_PATTERN_" + APP_NAME); } @Test @@ -218,35 +217,171 @@ void test_getStatusChangedMessage_parsesThemeColorFromSpelExpression() { Message message = notifier.getStatusChangedMessage(instance, notifier.createEvaluationContext( new InstanceStatusChangedEvent(instance.getId(), 1L, StatusInfo.ofUp()), instance)); - assertThat(message.getThemeColor()).isEqualTo("green"); - } - - private void assertMessage(Message message, String expectedTitle, String expectedSummary, String expectedSubTitle, - String expectedColor) { - assertThat(message.getTitle()).isEqualTo(expectedTitle); - assertThat(message.getSummary()).isEqualTo(expectedSummary); - assertThat(message.getThemeColor()).isEqualTo(expectedColor); - - assertThat(message.getSections()).hasSize(1).anySatisfy((section) -> { - assertThat(section.getActivityTitle()).isEqualTo(instance.getRegistration().getName()); - assertThat(section.getActivitySubtitle()).isEqualTo(expectedSubTitle); - - assertThat(section.getFacts()).hasSize(5).anySatisfy((fact) -> { - assertThat(fact.name()).isEqualTo("Status"); - assertThat(fact.value()).isEqualTo("UNKNOWN"); - }).anySatisfy((fact) -> { - assertThat(fact.name()).isEqualTo("Service URL"); - assertThat(fact.value()).isEqualTo(SERVICE_URL); - }).anySatisfy((fact) -> { - assertThat(fact.name()).isEqualTo("Health URL"); - assertThat(fact.value()).isEqualTo(HEALTH_URL); - }).anySatisfy((fact) -> { - assertThat(fact.name()).isEqualTo("Management URL"); - assertThat(fact.value()).isEqualTo(MANAGEMENT_URL); - }).anySatisfy((fact) -> { - assertThat(fact.name()).isEqualTo("Source"); - assertThat(fact.value()).isNull(); - }); + assertThat(getColorFromMessage(message)).isEqualTo("green"); + } + + @Test + void test_messageSerializesToExpectedJsonStructure() throws Exception { + // Update instance to UP status + Instance upInstance = Instance.create(instance.getId()) + .register(instance.getRegistration()) + .withStatusInfo(StatusInfo.ofUp()); + + Message message = notifier.getStatusChangedMessage(upInstance, notifier.createEvaluationContext( + new InstanceStatusChangedEvent(upInstance.getId(), 1L, StatusInfo.ofUp()), upInstance)); + + // Build expected JSON structure using JSONObject with actual values + JSONObject expectedJson = new JSONObject(""" + { + "type": "message", + "attachments": [{ + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": null, + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.2", + "body": [ + { + "type": "TextBlock", + "text": "Status Changed", + "size": "Large", + "weight": "Bolder", + "color": "Good" + }, + { + "type": "TextBlock", + "text": "Test App", + "size": "Medium", + "weight": "Bolder" + }, + { + "type": "TextBlock", + "text": "Test App with id TestAppId changed status from UNKNOWN to UP", + "wrap": true + }, + { + "type": "FactSet", + "facts": [ + {"title": "Status", "value": "UP"}, + {"title": "Service URL", "value": "https://service"}, + {"title": "Health URL", "value": "https://health"}, + {"title": "Management URL", "value": "https://management"} + ] + } + ] + } + }] + } + """); + + // Verify message structure matches expected format + assertThat(message.getType()).isEqualTo(expectedJson.getString("type")); + + assertThat(message.getAttachments()).hasSize(1); + var attachment = message.getAttachments().get(0); + assertThat(attachment.getContentType()) + .isEqualTo(expectedJson.getJSONArray("attachments").getJSONObject(0).getString("contentType")); + + var content = attachment.getContent(); + var expectedContent = expectedJson.getJSONArray("attachments").getJSONObject(0).getJSONObject("content"); + assertThat(content.getSchema()).isEqualTo(expectedContent.getString("$schema")); + assertThat(content.getType()).isEqualTo(expectedContent.getString("type")); + assertThat(content.getVersion()).isEqualTo(expectedContent.getString("version")); + + // Verify body structure and content + var body = content.getBody(); + var expectedBody = expectedContent.getJSONArray("body"); + assertThat(body).hasSize(expectedBody.length()); + + // Verify Title TextBlock + assertThat(body.get(0).getType()).isEqualTo("TextBlock"); + assertThat(body.get(0).getText()).isEqualTo("Status Changed"); + assertThat(body.get(0).getSize()).isEqualTo("Large"); + assertThat(body.get(0).getWeight()).isEqualTo("Bolder"); + assertThat(body.get(0).getColor()).isEqualTo("Good"); + + // Verify Service Name TextBlock + assertThat(body.get(1).getType()).isEqualTo("TextBlock"); + assertThat(body.get(1).getText()).isEqualTo(APP_NAME); + assertThat(body.get(1).getSize()).isEqualTo("Medium"); + assertThat(body.get(1).getWeight()).isEqualTo("Bolder"); + + // Verify Activity Subtitle TextBlock + assertThat(body.get(2).getType()).isEqualTo("TextBlock"); + assertThat(body.get(2).getText()).isEqualTo("Test App with id TestAppId changed status from UNKNOWN to UP"); + assertThat(body.get(2).getWrap()).isTrue(); + + // Verify FactSet + assertThat(body.get(3).getType()).isEqualTo("FactSet"); + assertThat(body.get(3).getFacts()).hasSize(4); // Source is omitted because it's + // null + assertThat(body.get(3).getFacts().get(0).title()).isEqualTo("Status"); + assertThat(body.get(3).getFacts().get(0).value()).isEqualTo("UP"); + assertThat(body.get(3).getFacts().get(1).title()).isEqualTo("Service URL"); + assertThat(body.get(3).getFacts().get(1).value()).isEqualTo(SERVICE_URL); + assertThat(body.get(3).getFacts().get(2).title()).isEqualTo("Health URL"); + assertThat(body.get(3).getFacts().get(2).value()).isEqualTo(HEALTH_URL); + assertThat(body.get(3).getFacts().get(3).title()).isEqualTo("Management URL"); + assertThat(body.get(3).getFacts().get(3).value()).isEqualTo(MANAGEMENT_URL); + } + + private String getActivitySubtitleFromMessage(Message message) { + return message.getAttachments().get(0).getContent().getBody().get(2).getText(); + } + + private String getColorFromMessage(Message message) { + return message.getAttachments().get(0).getContent().getBody().get(0).getColor(); + } + + private void assertMessage(Message message, String expectedTitle, String expectedSubTitle, String expectedColor) { + assertThat(message.getType()).isEqualTo("message"); + assertThat(message.getAttachments()).hasSize(1); + + var attachment = message.getAttachments().get(0); + assertThat(attachment.getContentType()).isEqualTo("application/vnd.microsoft.card.adaptive"); + assertThat(attachment.getContentUrl()).isNull(); + + var card = attachment.getContent(); + assertThat(card.getType()).isEqualTo("AdaptiveCard"); + assertThat(card.getVersion()).isEqualTo("1.2"); + assertThat(card.getSchema()).isEqualTo("http://adaptivecards.io/schemas/adaptive-card.json"); + + var body = card.getBody(); + assertThat(body).hasSize(4); + + // Title + assertThat(body.get(0).getType()).isEqualTo("TextBlock"); + assertThat(body.get(0).getText()).isEqualTo(expectedTitle); + assertThat(body.get(0).getSize()).isEqualTo("Large"); + assertThat(body.get(0).getWeight()).isEqualTo("Bolder"); + assertThat(body.get(0).getColor()).isEqualTo(expectedColor); + + // Service Name + assertThat(body.get(1).getType()).isEqualTo("TextBlock"); + assertThat(body.get(1).getText()).isEqualTo(instance.getRegistration().getName()); + assertThat(body.get(1).getSize()).isEqualTo("Medium"); + assertThat(body.get(1).getWeight()).isEqualTo("Bolder"); + + // Activity Subtitle + assertThat(body.get(2).getType()).isEqualTo("TextBlock"); + assertThat(body.get(2).getText()).isEqualTo(expectedSubTitle); + assertThat(body.get(2).getWrap()).isTrue(); + + // Facts + assertThat(body.get(3).getType()).isEqualTo("FactSet"); + assertThat(body.get(3).getFacts()).hasSize(4).anySatisfy((fact) -> { + assertThat(fact.title()).isEqualTo("Status"); + assertThat(fact.value()).isEqualTo("UNKNOWN"); + }).anySatisfy((fact) -> { + assertThat(fact.title()).isEqualTo("Service URL"); + assertThat(fact.value()).isEqualTo(SERVICE_URL); + }).anySatisfy((fact) -> { + assertThat(fact.title()).isEqualTo("Health URL"); + assertThat(fact.value()).isEqualTo(HEALTH_URL); + }).anySatisfy((fact) -> { + assertThat(fact.title()).isEqualTo("Management URL"); + assertThat(fact.value()).isEqualTo(MANAGEMENT_URL); }); }