Skip to content

Commit

Permalink
Added support DingTalk notifier. (#1653)
Browse files Browse the repository at this point in the history
  • Loading branch information
mask616 committed Mar 26, 2021
1 parent 1357b42 commit a7d41b5
Show file tree
Hide file tree
Showing 4 changed files with 312 additions and 0 deletions.
26 changes: 26 additions & 0 deletions spring-boot-admin-docs/src/main/asciidoc/server-notifications.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -436,3 +436,29 @@ include::{samples-dir}/spring-boot-admin-sample-servlet/src/main/java/de/codecen
<2> Add the `RemindingNotifier` as primary bean using the `FilteringNotifier` as delegate.

TIP: This example combines the reminding and filtering notifiers. This allows you to get notifications after the deployed application hasn't restarted in a certain amount of time (until the filter expires).


[[DingTalk-notifications]]
==== DingTalk Notifications ====
To enable https://www.dingtalk.com/[DingTalk] notifications you need to create and authorize a dingtalk bot and set the appropriate configuration properties for webhookUrl and secret.

.DingTalk notifications configuration options
|===
| Property name |Description |Default value

| spring.boot.admin.notify.dingtalk.enabled
| Enable DingTalk notifications.
| `true`

| spring.boot.admin.notify.dingtalk.webhook-url
| The DingTalk webhook url to send the notifications to.
|

| spring.boot.admin.notify.dingtalk.secret
| The secret to get message sign.
|

| spring.boot.admin.notify.dingtalk.message
| Text to send. SpEL-expressions are supported.
| `+++"#{instance.registration.name} #{instance.id} is #{event.statusInfo.status} "+++`
|===
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.events.InstanceEvent;
import de.codecentric.boot.admin.server.notify.CompositeNotifier;
import de.codecentric.boot.admin.server.notify.DingTalkNotifier;
import de.codecentric.boot.admin.server.notify.DiscordNotifier;
import de.codecentric.boot.admin.server.notify.HipchatNotifier;
import de.codecentric.boot.admin.server.notify.LetsChatNotifier;
Expand Down Expand Up @@ -309,4 +310,19 @@ public DiscordNotifier discordNotifier(InstanceRepository repository, NotifierPr

}

@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(prefix = "spring.boot.admin.notify.dingtalk", name = "webhook-url")
@AutoConfigureBefore({ NotifierTriggerConfiguration.class, CompositeNotifierConfiguration.class })
@Lazy(false)
public static class DingTalkNotifierConfiguration {

@Bean
@ConditionalOnMissingBean
@ConfigurationProperties("spring.boot.admin.notify.dingtalk")
public DingTalkNotifier dingTalkNotifier(InstanceRepository repository, NotifierProxyProperties proxyProperties) {
return new DingTalkNotifier(repository, createNotifierRestTemplate(proxyProperties));
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* Copyright 2014-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package de.codecentric.boot.admin.server.notify;

import java.net.URLEncoder;
import java.util.HashMap;
import java.util.Map;

import javax.annotation.Nullable;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.apache.commons.codec.binary.Base64;
import org.springframework.context.expression.MapAccessor;
import org.springframework.expression.Expression;
import org.springframework.expression.ParserContext;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.client.RestTemplate;
import reactor.core.publisher.Mono;

import de.codecentric.boot.admin.server.domain.entities.Instance;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.events.InstanceEvent;

/**
* Notifier submitting events to DingTalk.
*
* @author Mask
*/
public class DingTalkNotifier extends AbstractStatusChangeNotifier {

private static final String DEFAULT_MESSAGE = "#{instance.registration.name} #{instance.id} is #{event.statusInfo.status}";

private final SpelExpressionParser parser = new SpelExpressionParser();

private RestTemplate restTemplate;

/**
* Webhook URI for the DingTalk API.
*/
private String webhookUrl;

/**
* Secret for DingTalk.
*/
@Nullable
private String secret;

private Expression message;

public DingTalkNotifier(InstanceRepository repository, RestTemplate restTemplate) {
super(repository);
this.restTemplate = restTemplate;
this.message = parser.parseExpression(DEFAULT_MESSAGE, ParserContext.TEMPLATE_EXPRESSION);
}

@Override
protected Mono<Void> doNotify(InstanceEvent event, Instance instance) {
return Mono
.fromRunnable(() -> restTemplate.postForEntity(buildUrl(), createMessage(event, instance), Void.class));
}

private String buildUrl() {
Long timestamp = System.currentTimeMillis();
return String.format("%s&timestamp=%s&sign=%s", webhookUrl, timestamp, getSign(timestamp));
}

protected Object createMessage(InstanceEvent event, Instance instance) {
Map<String, Object> messageJson = new HashMap<>();
messageJson.put("msgtype", "text");

Map<String, Object> content = new HashMap<>();
content.put("content", getText(event, instance));
messageJson.put("text", content);

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
return new HttpEntity<>(messageJson, headers);
}

private Object getText(InstanceEvent event, Instance instance) {
Map<String, Object> root = new HashMap<>();
root.put("event", event);
root.put("instance", instance);
root.put("lastStatus", getLastStatus(event.getInstance()));
StandardEvaluationContext context = new StandardEvaluationContext(root);
context.addPropertyAccessor(new MapAccessor());
return message.getValue(context, String.class);
}

private String getSign(Long timestamp) {
try {
String stringToSign = timestamp + "\n" + secret;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256"));
byte[] signData = mac.doFinal(stringToSign.getBytes("UTF-8"));
return URLEncoder.encode(new String(Base64.encodeBase64(signData)), "UTF-8");
}
catch (Exception ex) {
ex.printStackTrace();
}
return "";
}

public void setRestTemplate(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}

public String getWebhookUrl() {
return webhookUrl;
}

public void setWebhookUrl(String webhookUrl) {
this.webhookUrl = webhookUrl;
}

@Nullable
public String getSecret() {
return secret;
}

public void setSecret(@Nullable String secret) {
this.secret = secret;
}

public String getMessage() {
return message.getExpressionString();
}

public void setMessage(String message) {
this.message = parser.parseExpression(message, ParserContext.TEMPLATE_EXPRESSION);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Copyright 2014-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package de.codecentric.boot.admin.server.notify;

import java.util.HashMap;
import java.util.Map;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.client.RestTemplate;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import de.codecentric.boot.admin.server.domain.entities.Instance;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.events.InstanceStatusChangedEvent;
import de.codecentric.boot.admin.server.domain.values.InstanceId;
import de.codecentric.boot.admin.server.domain.values.Registration;
import de.codecentric.boot.admin.server.domain.values.StatusInfo;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

public class DingTalkNotifierTest {

private final Instance instance = Instance.create(InstanceId.of("-id-"))
.register(Registration.create("DingTalk", "http://health").build());

private InstanceRepository repository;

private DingTalkNotifier notifier;

private RestTemplate restTemplate;

@BeforeEach
public void setUp() {
repository = mock(InstanceRepository.class);
when(repository.find(instance.getId())).thenReturn(Mono.just(instance));

restTemplate = mock(RestTemplate.class);
notifier = new DingTalkNotifier(repository, restTemplate);
notifier.setWebhookUrl("https://dingtalk.com/");
notifier.setSecret("-secret-");
}

@Test
public void test_onApplicationEvent_resolve() {
StepVerifier
.create(notifier.notify(
new InstanceStatusChangedEvent(instance.getId(), instance.getVersion(), StatusInfo.ofDown())))
.verifyComplete();
clearInvocations(restTemplate);

StepVerifier
.create(notifier.notify(
new InstanceStatusChangedEvent(instance.getId(), instance.getVersion(), StatusInfo.ofUp())))
.verifyComplete();

Object expected = expectedMessage(standardMessage("UP"));

verify(restTemplate).postForEntity(any(String.class), eq(expected), eq(Void.class));
}

@Test
public void test_onApplicationEvent_trigger() {
StepVerifier
.create(notifier.notify(
new InstanceStatusChangedEvent(instance.getId(), instance.getVersion(), StatusInfo.ofUp())))
.verifyComplete();
StepVerifier
.create(notifier.notify(
new InstanceStatusChangedEvent(instance.getId(), instance.getVersion(), StatusInfo.ofDown())))
.verifyComplete();

Object expected = expectedMessage(standardMessage("DOWN"));

verify(restTemplate).postForEntity(any(String.class), eq(expected), eq(Void.class));
}

private HttpEntity<Map<String, Object>> expectedMessage(String message) {
Map<String, Object> messageJson = new HashMap<>();
messageJson.put("msgtype", "text");

Map<String, Object> content = new HashMap<>();
content.put("content", message);
messageJson.put("text", content);

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
return new HttpEntity<>(messageJson, headers);
}

private String standardMessage(String status) {
return instance.getRegistration().getName() + " " + instance.getId() + " is " + status;
}

}

0 comments on commit a7d41b5

Please sign in to comment.