Skip to content

Commit ab69099

Browse files
SteKoesweeter
andauthored
feat: Feishu notifier (#2462)
* Added support FeiShu notifier. * chore: add tests * chore: fix tests * fix checkstyle --------- Co-authored-by: sweeter <[email protected]>
1 parent 8c19f6c commit ab69099

File tree

3 files changed

+448
-0
lines changed

3 files changed

+448
-0
lines changed

spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerNotifierAutoConfiguration.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
import de.codecentric.boot.admin.server.notify.CompositeNotifier;
5757
import de.codecentric.boot.admin.server.notify.DingTalkNotifier;
5858
import de.codecentric.boot.admin.server.notify.DiscordNotifier;
59+
import de.codecentric.boot.admin.server.notify.FeiShuNotifier;
5960
import de.codecentric.boot.admin.server.notify.HipchatNotifier;
6061
import de.codecentric.boot.admin.server.notify.LetsChatNotifier;
6162
import de.codecentric.boot.admin.server.notify.MailNotifier;
@@ -347,4 +348,19 @@ public RocketChatNotifier rocketChatNotifier(InstanceRepository repository,
347348

348349
}
349350

351+
@Configuration(proxyBeanMethods = false)
352+
@ConditionalOnProperty(prefix = "spring.boot.admin.notify.feishu", name = "webhook-url")
353+
@AutoConfigureBefore({ NotifierTriggerConfiguration.class, CompositeNotifierConfiguration.class })
354+
@Lazy(false)
355+
public static class FeiShuNotifierConfiguration {
356+
357+
@Bean
358+
@ConditionalOnMissingBean
359+
@ConfigurationProperties("spring.boot.admin.notify.feishu")
360+
public FeiShuNotifier feiShuNotifier(InstanceRepository repository, NotifierProxyProperties proxyProperties) {
361+
return new FeiShuNotifier(repository, createNotifierRestTemplate(proxyProperties));
362+
}
363+
364+
}
365+
350366
}
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
/*
2+
* Copyright 2014-2022 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package de.codecentric.boot.admin.server.notify;
18+
19+
import java.net.URI;
20+
import java.nio.charset.StandardCharsets;
21+
import java.time.Instant;
22+
import java.util.ArrayList;
23+
import java.util.Base64;
24+
import java.util.HashMap;
25+
import java.util.List;
26+
import java.util.Map;
27+
import java.util.UUID;
28+
29+
import javax.crypto.Mac;
30+
import javax.crypto.spec.SecretKeySpec;
31+
32+
import com.fasterxml.jackson.databind.ObjectMapper;
33+
import org.slf4j.Logger;
34+
import org.slf4j.LoggerFactory;
35+
import org.springframework.context.expression.MapAccessor;
36+
import org.springframework.expression.Expression;
37+
import org.springframework.expression.ParserContext;
38+
import org.springframework.expression.spel.standard.SpelExpressionParser;
39+
import org.springframework.expression.spel.support.StandardEvaluationContext;
40+
import org.springframework.http.HttpEntity;
41+
import org.springframework.http.HttpHeaders;
42+
import org.springframework.http.MediaType;
43+
import org.springframework.http.ResponseEntity;
44+
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
45+
import org.springframework.util.StringUtils;
46+
import org.springframework.web.client.RestTemplate;
47+
import reactor.core.publisher.Mono;
48+
49+
import de.codecentric.boot.admin.server.domain.entities.Instance;
50+
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
51+
import de.codecentric.boot.admin.server.domain.events.InstanceEvent;
52+
53+
/**
54+
* Notifier submitting events to FeiShu by webhooks.
55+
*
56+
* @author sweeter
57+
* @see <a href=
58+
* "https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO24yNxkjN">https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO24yNxkjN</a>
59+
*
60+
*/
61+
public class FeiShuNotifier extends AbstractStatusChangeNotifier {
62+
63+
private static final String DEFAULT_MESSAGE = "ServiceName: #{instance.registration.name}(#{instance.id}) \nServiceUrl: #{instance.registration.serviceUrl} \nStatus: changed status from [#{lastStatus}] to [#{event.statusInfo.status}]";
64+
65+
private final Logger log = LoggerFactory.getLogger(this.getClass());
66+
67+
private final SpelExpressionParser parser = new SpelExpressionParser();
68+
69+
private RestTemplate restTemplate;
70+
71+
private Expression message;
72+
73+
/**
74+
* Webhook URL for the FeiShu(飞书) chat group API (i.e.
75+
* https://open.feishu.cn/open-apis/bot/v2/hook/xxx).
76+
*/
77+
private URI webhookUrl;
78+
79+
/**
80+
* @ all.
81+
*/
82+
private boolean atAll = true;
83+
84+
/**
85+
* The secret of the chat group robot from the FeiShu setup.
86+
*/
87+
private String secret;
88+
89+
/**
90+
* FeiShu message type: text(文本) interactive(消息卡片)
91+
*/
92+
private MessageType messageType = MessageType.interactive;
93+
94+
/**
95+
* Card theme message
96+
*/
97+
private Card card = new Card();
98+
99+
public FeiShuNotifier(InstanceRepository repository, RestTemplate restTemplate) {
100+
super(repository);
101+
this.restTemplate = restTemplate;
102+
this.message = this.parser.parseExpression(DEFAULT_MESSAGE, ParserContext.TEMPLATE_EXPRESSION);
103+
}
104+
105+
@Override
106+
protected Mono<Void> doNotify(InstanceEvent event, Instance instance) {
107+
if (webhookUrl == null) {
108+
return Mono.error(new IllegalStateException("'webhookUrl' must not be null."));
109+
}
110+
return Mono.fromRunnable(() -> {
111+
ResponseEntity<String> responseEntity = this.restTemplate.postForEntity(this.webhookUrl,
112+
this.createNotification(event, instance), String.class);
113+
log.debug("Send a notification message to the FeiShu group,returns the parameter:{}",
114+
responseEntity.getBody());
115+
});
116+
}
117+
118+
private String generateSign(String secret, long timestamp) {
119+
try {
120+
String stringToSign = timestamp + "\n" + secret;
121+
Mac mac = Mac.getInstance("HmacSHA256");
122+
mac.init(new SecretKeySpec(stringToSign.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
123+
byte[] signData = mac.doFinal(new byte[] {});
124+
return new String(Base64.getEncoder().encode(signData));
125+
}
126+
catch (Exception ex) {
127+
log.error("Description Failed to generate the Webhook signature of the FeiShu:{}", ex.getMessage());
128+
}
129+
return null;
130+
}
131+
132+
protected HttpEntity<Map<String, Object>> createNotification(InstanceEvent event, Instance instance) {
133+
Map<String, Object> body = new HashMap<>();
134+
body.put("receive_id", UUID.randomUUID().toString());
135+
if (StringUtils.hasText(this.secret)) {
136+
long timestamp = Instant.now().getEpochSecond();
137+
body.put("timestamp", timestamp);
138+
body.put("sign", this.generateSign(this.secret, timestamp));
139+
}
140+
body.put("msg_type", this.messageType);
141+
switch (this.messageType) {
142+
case interactive:
143+
body.put("card", this.createCardContent(event, instance));
144+
break;
145+
case text:
146+
body.put("content", this.createTextContent(event, instance));
147+
break;
148+
149+
default:
150+
body.put("content", this.createTextContent(event, instance));
151+
}
152+
HttpHeaders headers = new HttpHeaders();
153+
headers.setContentType(MediaType.APPLICATION_JSON);
154+
headers.add("User-Agent", "Codecentric's Spring Boot Admin");
155+
return new HttpEntity<>(body, headers);
156+
}
157+
158+
private String createContent(InstanceEvent event, Instance instance) {
159+
Map<String, Object> root = new HashMap<>();
160+
root.put("event", event);
161+
root.put("instance", instance);
162+
root.put("lastStatus", this.getLastStatus(event.getInstance()));
163+
StandardEvaluationContext context = new StandardEvaluationContext(root);
164+
context.addPropertyAccessor(new MapAccessor());
165+
return this.message.getValue(context, String.class);
166+
}
167+
168+
private String createTextContent(InstanceEvent event, Instance instance) {
169+
Map<String, Object> textContent = new HashMap<>();
170+
String content = this.createContent(event, instance);
171+
if (this.atAll) {
172+
content += "\n<at user_id=\"all\">@all</at>";
173+
}
174+
textContent.put("text", content);
175+
return this.toJsonString(textContent);
176+
}
177+
178+
private String createCardContent(InstanceEvent event, Instance instance) {
179+
String content = this.createContent(event, instance);
180+
Card card = this.card;
181+
182+
Map<String, Object> header = new HashMap<>();
183+
header.put("template", StringUtils.hasText(card.getThemeColor()) ? "red" : card.getThemeColor());
184+
Map<String, String> titleContent = new HashMap<>();
185+
titleContent.put("tag", "plain_text");
186+
titleContent.put("content", card.getTitle());
187+
header.put("title", titleContent);
188+
189+
List<Map<String, Object>> elements = new ArrayList<>();
190+
Map<String, Object> item = new HashMap<>();
191+
item.put("tag", "div");
192+
193+
Map<String, String> text = new HashMap<>();
194+
text.put("tag", "plain_text");
195+
text.put("content", content);
196+
item.put("text", text);
197+
elements.add(item);
198+
199+
if (this.atAll) {
200+
Map<String, Object> atItem = new HashMap<>();
201+
atItem.put("tag", "div");
202+
Map<String, String> atText = new HashMap<>();
203+
atText.put("tag", "lark_md");
204+
atText.put("content", "<at id=all></at>");
205+
atItem.put("text", atText);
206+
elements.add(atItem);
207+
}
208+
Map<String, Object> cardContent = new HashMap<>();
209+
cardContent.put("header", header);
210+
cardContent.put("elements", elements);
211+
return this.toJsonString(cardContent);
212+
}
213+
214+
private String toJsonString(Object o) {
215+
try {
216+
ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json().build();
217+
return objectMapper.writeValueAsString(o);
218+
}
219+
catch (Exception ex) {
220+
ex.printStackTrace();
221+
}
222+
return null;
223+
}
224+
225+
public URI getWebhookUrl() {
226+
return this.webhookUrl;
227+
}
228+
229+
public void setWebhookUrl(URI webhookUrl) {
230+
this.webhookUrl = webhookUrl;
231+
}
232+
233+
public String getMessage() {
234+
return this.message.getExpressionString();
235+
}
236+
237+
public void setMessage(String message) {
238+
this.message = this.parser.parseExpression(message, ParserContext.TEMPLATE_EXPRESSION);
239+
}
240+
241+
public void setRestTemplate(RestTemplate restTemplate) {
242+
this.restTemplate = restTemplate;
243+
}
244+
245+
public boolean isAtAll() {
246+
return atAll;
247+
}
248+
249+
public void setAtAll(boolean atAll) {
250+
this.atAll = atAll;
251+
}
252+
253+
public String getSecret() {
254+
return secret;
255+
}
256+
257+
public void setSecret(String secret) {
258+
this.secret = secret;
259+
}
260+
261+
public MessageType getMessageType() {
262+
return messageType;
263+
}
264+
265+
public void setMessageType(MessageType messageType) {
266+
this.messageType = messageType;
267+
}
268+
269+
public Card getCard() {
270+
return card;
271+
}
272+
273+
public void setCard(Card card) {
274+
this.card = card;
275+
}
276+
277+
public enum MessageType {
278+
279+
text, interactive
280+
281+
}
282+
283+
public class Card {
284+
285+
/**
286+
* This is header title.
287+
*/
288+
private String title = "Codecentric's Spring Boot Admin notice";
289+
290+
private String themeColor = "red";
291+
292+
public String getTitle() {
293+
return title;
294+
}
295+
296+
public void setTitle(String title) {
297+
this.title = title;
298+
}
299+
300+
public String getThemeColor() {
301+
return themeColor;
302+
}
303+
304+
public void setThemeColor(String themeColor) {
305+
this.themeColor = themeColor;
306+
}
307+
308+
}
309+
310+
}

0 commit comments

Comments
 (0)