Notification rule node; refactoring

This commit is contained in:
ViacheslavKlimov 2023-04-03 19:02:23 +03:00
parent 4e8e33ca1c
commit cd50eabd40
16 changed files with 192 additions and 62 deletions

View File

@ -193,7 +193,7 @@ public class NotificationController extends BaseController {
notificationRequest.setStatus(null);
notificationRequest.setStats(null);
return doSaveAndLog(EntityType.NOTIFICATION_REQUEST, notificationRequest, notificationCenter::processNotificationRequest);
return doSaveAndLog(EntityType.NOTIFICATION_REQUEST, notificationRequest, (tenantId, request) -> notificationCenter.processNotificationRequest(tenantId, request, null));
}
@PostMapping("/notification/request/preview")

View File

@ -82,6 +82,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.stream.Collectors;
@Service
@ -105,7 +106,7 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple
private Map<NotificationDeliveryMethod, NotificationChannel> channels;
@Override
public NotificationRequest processNotificationRequest(TenantId tenantId, NotificationRequest request) {
public NotificationRequest processNotificationRequest(TenantId tenantId, NotificationRequest request, Consumer<NotificationRequestStats> callback) {
if (!rateLimitService.checkRateLimit(tenantId, LimitedApi.NOTIFICATION_REQUEST)) {
throw new TbRateLimitsException(EntityType.TENANT);
}
@ -173,6 +174,14 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple
} catch (Exception e) {
log.error("[{}] Failed to update stats for notification request", requestId, e);
}
if (callback != null) {
try {
callback.accept(stats);
} catch (Exception e) {
log.error("Failed to process callback for notification request {}", requestId, e);
}
}
}, dbCallbackExecutorService);
});

View File

@ -110,7 +110,7 @@ public class DefaultNotificationSchedulerService extends AbstractPartitionBasedS
notificationExecutor.executeAsync(() -> {
try {
notificationCenter.processNotificationRequest(tenantId, notificationRequest);
notificationCenter.processNotificationRequest(tenantId, notificationRequest, null);
} catch (Exception e) {
log.error("Failed to process scheduled notification request {}", notificationRequest.getId(), e);
NotificationRequestStats stats = new NotificationRequestStats();

View File

@ -19,7 +19,6 @@ import com.google.common.base.Strings;
import lombok.Builder;
import lombok.Getter;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.StringUtils;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
import org.thingsboard.server.common.data.notification.NotificationRequest;
@ -32,12 +31,12 @@ import org.thingsboard.server.common.data.notification.template.HasSubject;
import org.thingsboard.server.common.data.notification.template.NotificationTemplate;
import org.thingsboard.server.common.data.notification.template.NotificationTemplateConfig;
import org.thingsboard.server.common.data.notification.template.WebDeliveryMethodNotificationTemplate;
import org.thingsboard.server.common.data.util.TemplateUtils;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import static org.apache.commons.lang3.StringUtils.isNotEmpty;
@ -58,7 +57,6 @@ public class NotificationProcessingContext {
@Getter
private final NotificationRequestStats stats;
private static final Pattern TEMPLATE_PARAM_PATTERN = Pattern.compile("\\$\\{([a-zA-Z]+)(:[a-zA-Z]+)?}");
@Builder
public NotificationProcessingContext(TenantId tenantId, NotificationRequest request, NotificationTemplate template, NotificationSettings settings) {
@ -109,47 +107,25 @@ public class NotificationProcessingContext {
if (templateContext.isEmpty()) return template;
template = (T) template.copy();
template.setBody(processTemplate(template.getBody(), templateContext));
template.setBody(TemplateUtils.processTemplate(template.getBody(), templateContext));
if (template instanceof HasSubject) {
String subject = ((HasSubject) template).getSubject();
((HasSubject) template).setSubject(processTemplate(subject, templateContext));
((HasSubject) template).setSubject(TemplateUtils.processTemplate(subject, templateContext));
}
if (template instanceof WebDeliveryMethodNotificationTemplate) {
WebDeliveryMethodNotificationTemplate webNotificationTemplate = (WebDeliveryMethodNotificationTemplate) template;
String buttonText = webNotificationTemplate.getButtonText();
if (isNotEmpty(buttonText)) {
webNotificationTemplate.setButtonText(processTemplate(buttonText, templateContext));
webNotificationTemplate.setButtonText(TemplateUtils.processTemplate(buttonText, templateContext));
}
String buttonLink = webNotificationTemplate.getButtonLink();
if (isNotEmpty(buttonLink)) {
webNotificationTemplate.setButtonLink(processTemplate(buttonLink, templateContext));
webNotificationTemplate.setButtonLink(TemplateUtils.processTemplate(buttonLink, templateContext));
}
}
return template;
}
private static String processTemplate(String template, Map<String, String> context) {
return TEMPLATE_PARAM_PATTERN.matcher(template).replaceAll(matchResult -> {
String key = matchResult.group(1);
if (!context.containsKey(key)) {
return "\\" + matchResult.group(); // adding escape char due to special meaning of '$' to matcher
}
String value = Strings.nullToEmpty(context.get(key));
String function = matchResult.group(2);
if (function != null) {
switch (function) {
case ":upperCase":
return value.toUpperCase();
case ":lowerCase":
return value.toLowerCase();
case ":capitalize":
return StringUtils.capitalize(value.toLowerCase());
}
}
return value;
});
}
private Map<String, String> createTemplateContextForRecipient(NotificationRecipient recipient) {
return Map.of(
"recipientEmail", Strings.nullToEmpty(recipient.getEmail()),

View File

@ -154,7 +154,7 @@ public class DefaultNotificationRuleProcessingService implements NotificationRul
notificationExecutor.submit(() -> {
try {
log.debug("Submitting notification request for rule '{}' with delay of {} sec to targets {}", rule.getName(), delayInSec, targets);
notificationCenter.processNotificationRequest(rule.getTenantId(), notificationRequest);
notificationCenter.processNotificationRequest(rule.getTenantId(), notificationRequest, null);
} catch (Exception e) {
log.error("Failed to process notification request for tenant {} for rule {}", rule.getTenantId(), rule.getId(), e);
}

View File

@ -24,7 +24,6 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.id.NotificationTargetId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.notification.Notification;
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
import org.thingsboard.server.common.data.notification.NotificationRequest;
@ -333,8 +332,8 @@ public class NotificationApiTest extends AbstractNotificationApiTest {
target1Config.setUsersFilter(userListFilter);
target1.setConfiguration(target1Config);
target1 = saveNotificationTarget(target1);
List<UserId> recipients = new ArrayList<>();
recipients.add(tenantAdminUserId);
List<String> recipients = new ArrayList<>();
recipients.add(TENANT_ADMIN_EMAIL);
createDifferentCustomer();
loginTenantAdmin();
@ -346,7 +345,7 @@ public class NotificationApiTest extends AbstractNotificationApiTest {
customerUser.setCustomerId(differentCustomerId);
customerUser.setEmail("other-customer-" + i + "@thingsboard.org");
customerUser = createUser(customerUser, "12345678");
recipients.add(customerUser.getId());
recipients.add(customerUser.getEmail());
}
NotificationTarget target2 = new NotificationTarget();
target2.setName("Other customer users");
@ -402,7 +401,7 @@ public class NotificationApiTest extends AbstractNotificationApiTest {
assertThat(preview.getRecipientsCountByTarget().get(target1.getName())).isEqualTo(1);
assertThat(preview.getRecipientsCountByTarget().get(target2.getName())).isEqualTo(customerUsersCount);
assertThat(preview.getTotalRecipientsCount()).isEqualTo(1 + customerUsersCount);
assertThat(preview.getRecipientsPreview()).extracting(User::getId).containsAll(recipients);
assertThat(preview.getRecipientsPreview()).containsAll(recipients);
Map<NotificationDeliveryMethod, DeliveryMethodNotificationTemplate> processedTemplates = preview.getProcessedTemplates();
assertThat(processedTemplates.get(NotificationDeliveryMethod.WEB)).asInstanceOf(type(WebDeliveryMethodNotificationTemplate.class))
@ -485,7 +484,7 @@ public class NotificationApiTest extends AbstractNotificationApiTest {
NotificationTemplateConfig config = new NotificationTemplateConfig();
SlackDeliveryMethodNotificationTemplate slackNotificationTemplate = new SlackDeliveryMethodNotificationTemplate();
slackNotificationTemplate.setEnabled(true);
slackNotificationTemplate.setBody("To Slack :) ${recipientEmail}");
slackNotificationTemplate.setBody("To Slack :)");
config.setDeliveryMethodsTemplates(Map.of(
NotificationDeliveryMethod.SLACK, slackNotificationTemplate
));

View File

@ -26,6 +26,7 @@ public enum NotificationType {
ALARM_ASSIGNMENT,
NEW_PLATFORM_VERSION,
ENTITIES_LIMIT,
API_USAGE_LIMIT
API_USAGE_LIMIT,
RULE_ENGINE
}

View File

@ -33,10 +33,13 @@ public class RuleEngineOriginatedNotificationInfo implements NotificationInfo {
private EntityId msgOriginator;
private String msgType;
private Map<String, String> msgMetadata;
private Map<String, String> msgData;
@Override
public Map<String, String> getTemplateData() {
Map<String, String> templateData = new HashMap<>(msgMetadata);
Map<String, String> templateData = new HashMap<>();
templateData.putAll(msgMetadata);
templateData.putAll(msgData);
templateData.put("originatorType", msgOriginator.getEntityType().getNormalName());
templateData.put("originatorId", msgOriginator.getId().toString());
templateData.put("msgType", msgType);

View File

@ -0,0 +1,56 @@
/**
* Copyright © 2016-2023 The Thingsboard 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 org.thingsboard.server.common.data.util;
import org.apache.commons.lang3.StringUtils;
import java.util.Map;
import java.util.function.UnaryOperator;
import java.util.regex.Pattern;
import static com.google.common.base.Strings.nullToEmpty;
import static org.apache.commons.lang3.StringUtils.removeStart;
public class TemplateUtils {
private static final Pattern TEMPLATE_PARAM_PATTERN = Pattern.compile("\\$\\{(.+?)(:[a-zA-Z]+)?}");
private static final Map<String, UnaryOperator<String>> FUNCTIONS = Map.of(
"upperCase", String::toUpperCase,
"lowerCase", String::toLowerCase,
"capitalize", StringUtils::capitalize
);
private TemplateUtils() {}
public static String processTemplate(String template, Map<String, String> context) {
return TEMPLATE_PARAM_PATTERN.matcher(template).replaceAll(matchResult -> {
String key = matchResult.group(1);
if (!context.containsKey(key)) {
return "\\" + matchResult.group();
}
String value = nullToEmpty(context.get(key));
String function = removeStart(matchResult.group(2), ":");
if (function != null) {
if (FUNCTIONS.containsKey(function)) {
value = FUNCTIONS.get(function).apply(value);
}
}
return value;
});
}
}

View File

@ -32,7 +32,10 @@ import org.thingsboard.server.common.data.kv.KvEntry;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.UnaryOperator;
@ -237,6 +240,25 @@ public class JacksonUtil {
}
}
public static Map<String, String> toFlatMap(JsonNode node) {
HashMap<String, String> map = new HashMap<>();
toFlatMap(node, "", map);
return map;
}
private static void toFlatMap(JsonNode node, String currentPath, Map<String, String> map) {
if (node.isObject()) {
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
currentPath = currentPath.isEmpty() ? "" : currentPath + ".";
while (fields.hasNext()) {
Map.Entry<String, JsonNode> entry = fields.next();
toFlatMap(entry.getValue(), currentPath + entry.getKey(), map);
}
} else if (node.isValueNode()) {
map.put(currentPath, node.asText());
}
}
public static void addKvEntry(ObjectNode entityNode, KvEntry kvEntry) {
addKvEntry(entityNode, kvEntry, kvEntry.getKey());
}

View File

@ -21,12 +21,14 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
import org.thingsboard.server.common.data.notification.NotificationRequest;
import org.thingsboard.server.common.data.notification.NotificationRequestStats;
import java.util.Set;
import java.util.function.Consumer;
public interface NotificationCenter {
NotificationRequest processNotificationRequest(TenantId tenantId, NotificationRequest notificationRequest);
NotificationRequest processNotificationRequest(TenantId tenantId, NotificationRequest notificationRequest, Consumer<NotificationRequestStats> callback);
void deleteNotificationRequest(TenantId tenantId, NotificationRequestId notificationRequestId);

View File

@ -16,12 +16,14 @@
package org.thingsboard.rule.engine.notification;
import org.thingsboard.common.util.DonAsynchron;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.RuleNode;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNode;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.rule.engine.api.util.TbNodeUtils;
import org.thingsboard.server.common.data.id.NotificationTemplateId;
import org.thingsboard.server.common.data.notification.NotificationRequest;
import org.thingsboard.server.common.data.notification.NotificationRequestConfig;
import org.thingsboard.server.common.data.notification.info.RuleEngineOriginatedNotificationInfo;
@ -36,7 +38,7 @@ import java.util.concurrent.ExecutionException;
name = "send notification",
configClazz = TbNotificationNodeConfiguration.class,
nodeDescription = "Sends notification to targets using the template",
nodeDetails = "Will send notification to the specified targets",
nodeDetails = "Will send notification to the specified targets using the template",
uiResources = {"static/rulenode/rulenode-core-config.js"}
)
public class TbNotificationNode implements TbNode {
@ -53,29 +55,28 @@ public class TbNotificationNode implements TbNode {
RuleEngineOriginatedNotificationInfo notificationInfo = RuleEngineOriginatedNotificationInfo.builder()
.msgOriginator(msg.getOriginator())
.msgMetadata(msg.getMetaData().getData())
.msgData(JacksonUtil.toFlatMap(JacksonUtil.toJsonNode(msg.getData())))
.msgType(msg.getType())
.build();
NotificationRequest notificationRequest = NotificationRequest.builder()
.tenantId(ctx.getTenantId())
.targets(config.getTargets())
.templateId(config.getTemplateId())
.templateId(new NotificationTemplateId(config.getTemplateId()))
.info(notificationInfo)
.additionalConfig(new NotificationRequestConfig())
.originatorEntityId(ctx.getSelf().getRuleChainId())
.build();
DonAsynchron.withCallback(ctx.getNotificationExecutor().executeAsync(() -> {
return ctx.getNotificationCenter().processNotificationRequest(ctx.getTenantId(), notificationRequest);
return ctx.getNotificationCenter().processNotificationRequest(ctx.getTenantId(), notificationRequest, stats -> {
TbMsgMetaData metaData = msg.getMetaData().copy();
metaData.putValue("notificationRequestResult", JacksonUtil.toString(stats));
ctx.tellSuccess(TbMsg.transformMsg(msg, metaData));
});
}),
r -> {
TbMsgMetaData msgMetaData = msg.getMetaData().copy();
msgMetaData.putValue("notificationRequestId", r.getUuidId().toString());
ctx.tellSuccess(TbMsg.transformMsg(msg, msgMetaData));
},
e -> {
ctx.tellFailure(msg, e);
});
r -> {},
e -> ctx.tellFailure(msg, e));
}
}

View File

@ -32,14 +32,11 @@ public class TbNotificationNodeConfiguration implements NodeConfiguration<TbNoti
@NotEmpty
private List<UUID> targets;
@NotNull
private NotificationTemplateId templateId;
private NotificationRequestConfig additionalConfig;
private UUID templateId;
@Override
public TbNotificationNodeConfiguration defaultConfiguration() {
TbNotificationNodeConfiguration config = new TbNotificationNodeConfiguration();
config.setAdditionalConfig(new NotificationRequestConfig());
return config;
return new TbNotificationNodeConfiguration();
}
}

View File

@ -441,7 +441,8 @@ export enum NotificationType {
ALARM_ASSIGNMENT = 'ALARM_ASSIGNMENT',
RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT = 'RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT',
ENTITIES_LIMIT = 'ENTITIES_LIMIT',
API_USAGE_LIMIT = 'API_USAGE_LIMIT'
API_USAGE_LIMIT = 'API_USAGE_LIMIT',
RULE_ENGINE = 'RULE_ENGINE'
}
export const NotificationTypeIcons = new Map<NotificationType, string | null>([
@ -527,12 +528,20 @@ export const NotificationTemplateTypeTranslateMap = new Map<NotificationType, No
{
name: 'notification.template-type.entities-limit',
helpId: 'notification/entities_limit'
}],
}
],
[NotificationType.API_USAGE_LIMIT,
{
name: 'notification.template-type.api-usage-limit',
helpId: 'notification/api_usage_limit'
}]
}
],
[NotificationType.RULE_ENGINE,
{
name: 'notification.template-type.rule-engine',
helpId: 'notification/rule-engine'
}
]
]);
export enum TriggerType {

View File

@ -0,0 +1,54 @@
#### Rule engine notification templatization
<div class="divider"></div>
<br/>
Notification subject and message fields support templatization. The list of available templatization parameters depends on the template type.
See the available types and parameters below:
Available template parameters:
* values from the incoming message metadata;
* values from the incoming message data;
* *originatorType* - type of the originator, e.g. 'Device';
* *originatorId* - id of the originator
* *msgType* - type of the message
* *recipientEmail* - email of the recipient;
* *recipientFirstName* - first name of the recipient;
* *recipientLastName* - last name of the recipient;
Parameter names must be wrapped using `${...}`. For example: `${recipientFirstName}`.
You may also modify the value of the parameter with one of the suffixes:
* `upperCase`, for example - `${recipientFirstName:upperCase}`
* `lowerCase`, for example - `${recipientFirstName:lowerCase}`
* `capitalize`, for example - `${recipientFirstName:capitalize}`
<div class="divider"></div>
##### Examples
Let's assume the incoming message to Rule node has the following data:
```json
{
"building_1": {
"temperature": 24
}
}
```
The following template:
```text
Building 1: temperature is ${building_1.temperature}
{:copy-code}
```
will be transformed to:
```text
Building 1: temperature is 24
```
<br>
<br>

View File

@ -2931,7 +2931,8 @@
"entities-limit": "Entities limit",
"entity-action": "Entity action",
"general": "General",
"rule-engine-lifecycle-event": "Rule engine lifecycle event"
"rule-engine-lifecycle-event": "Rule engine lifecycle event",
"rule-engine": "Rule engine"
},
"templates": "Templates",
"tenant-profiles-list-rule-hint": "If the field is empty, the trigger will be applied to all tenant profiles",