diff --git a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java index 0f4a288e33..2eb98d2f38 100644 --- a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java @@ -62,6 +62,7 @@ import org.thingsboard.server.dao.notification.NotificationTargetService; import org.thingsboard.server.dao.notification.NotificationTemplateService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.notification.NotificationProcessingContext; +import org.thingsboard.server.service.notification.TemplateService; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; @@ -102,6 +103,7 @@ public class NotificationController extends BaseController { private final NotificationTargetService notificationTargetService; private final NotificationCenter notificationCenter; private final NotificationSettingsService notificationSettingsService; + private final TemplateService templateService; @ApiOperation(value = "Get notifications (getNotifications)", notes = "Returns the page of notifications for current user." + NEW_LINE + @@ -334,6 +336,7 @@ public class NotificationController extends BaseController { .deliveryMethods(deliveryMethods) .template(template) .settings(null) + .templateService(templateService) .build(); Map processedTemplates = ctx.getDeliveryMethods().stream() .collect(Collectors.toMap(m -> m, deliveryMethod -> { diff --git a/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java b/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java index a8e3caad1f..0518630597 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java @@ -95,12 +95,12 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple private final NotificationService notificationService; private final NotificationTemplateService notificationTemplateService; private final NotificationSettingsService notificationSettingsService; - private final UserService userService; private final NotificationExecutorService notificationExecutor; private final DbCallbackExecutorService dbCallbackExecutorService; private final NotificationsTopicService notificationsTopicService; private final TbQueueProducerProvider producerProvider; private final RateLimitService rateLimitService; + private final TemplateService templateService; private Map channels; @@ -169,6 +169,7 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple .deliveryMethods(deliveryMethods) .template(notificationTemplate) .settings(settings) + .templateService(templateService) .build(); notificationExecutor.submit(() -> { diff --git a/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java b/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java index 27a9cabe43..85076d2cb1 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.notification; +import com.google.common.base.Function; import com.google.common.base.Strings; import lombok.Builder; import lombok.Getter; @@ -31,12 +32,13 @@ 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.Collections; import java.util.EnumMap; import java.util.HashMap; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import static org.apache.commons.lang3.StringUtils.isNotEmpty; @@ -57,9 +59,11 @@ public class NotificationProcessingContext { @Getter private final NotificationRequestStats stats; + private final TemplateService templateService; + @Builder public NotificationProcessingContext(TenantId tenantId, NotificationRequest request, Set deliveryMethods, - NotificationTemplate template, NotificationSettings settings) { + NotificationTemplate template, NotificationSettings settings, TemplateService templateService) { this.tenantId = tenantId; this.request = request; this.deliveryMethods = deliveryMethods; @@ -67,14 +71,22 @@ public class NotificationProcessingContext { this.notificationTemplate = template; this.templates = new EnumMap<>(NotificationDeliveryMethod.class); this.stats = new NotificationRequestStats(); + this.templateService = templateService; init(); } + private final Map> recipientTemplateParams = Map.of( + "recipientTitle", NotificationRecipient::getTitle, + "recipientEmail", recipient -> Strings.nullToEmpty(recipient.getEmail()), + "recipientFirstName", recipient -> Strings.nullToEmpty(recipient.getFirstName()), + "recipientLastName", recipient -> Strings.nullToEmpty(recipient.getLastName()) + ); + private void init() { NotificationTemplateConfig templateConfig = notificationTemplate.getConfiguration(); templateConfig.getDeliveryMethodsTemplates().forEach((deliveryMethod, template) -> { if (template.isEnabled()) { - template = processTemplate(template, null); // processing template with immutable params + template = processTemplate(template, null, recipientTemplateParams.keySet()); templates.put(deliveryMethod, template); } }); @@ -91,12 +103,12 @@ public class NotificationProcessingContext { additionalTemplateContext = createTemplateContextForRecipient(recipient); } if (MapUtils.isNotEmpty(additionalTemplateContext) && template.containsAny(additionalTemplateContext.keySet().toArray(String[]::new))) { - template = processTemplate(template, additionalTemplateContext); + template = processTemplate(template, additionalTemplateContext, Collections.emptySet()); } return template; } - private T processTemplate(T template, Map additionalTemplateContext) { + private T processTemplate(T template, Map additionalTemplateContext, Set ignoredParams) { Map templateContext = new HashMap<>(); if (request.getInfo() != null) { templateContext.putAll(request.getInfo().getTemplateData()); @@ -104,35 +116,31 @@ public class NotificationProcessingContext { if (additionalTemplateContext != null) { templateContext.putAll(additionalTemplateContext); } - if (templateContext.isEmpty()) return template; + if (templateContext.isEmpty() && ignoredParams.isEmpty()) return template; template = (T) template.copy(); - template.setBody(TemplateUtils.processTemplate(template.getBody(), templateContext)); + template.setBody(templateService.processTemplate(tenantId, template.getBody(), templateContext, ignoredParams)); if (template instanceof HasSubject) { String subject = ((HasSubject) template).getSubject(); - ((HasSubject) template).setSubject(TemplateUtils.processTemplate(subject, templateContext)); + ((HasSubject) template).setSubject(templateService.processTemplate(tenantId, subject, templateContext, ignoredParams)); } if (template instanceof WebDeliveryMethodNotificationTemplate) { WebDeliveryMethodNotificationTemplate webNotificationTemplate = (WebDeliveryMethodNotificationTemplate) template; String buttonText = webNotificationTemplate.getButtonText(); if (isNotEmpty(buttonText)) { - webNotificationTemplate.setButtonText(TemplateUtils.processTemplate(buttonText, templateContext)); + webNotificationTemplate.setButtonText(templateService.processTemplate(tenantId, buttonText, templateContext, ignoredParams)); } String buttonLink = webNotificationTemplate.getButtonLink(); if (isNotEmpty(buttonLink)) { - webNotificationTemplate.setButtonLink(TemplateUtils.processTemplate(buttonLink, templateContext)); + webNotificationTemplate.setButtonLink(templateService.processTemplate(tenantId, buttonLink, templateContext, ignoredParams)); } } return template; } private Map createTemplateContextForRecipient(NotificationRecipient recipient) { - return Map.of( - "recipientTitle", recipient.getTitle(), - "recipientEmail", Strings.nullToEmpty(recipient.getEmail()), - "recipientFirstName", Strings.nullToEmpty(recipient.getFirstName()), - "recipientLastName", Strings.nullToEmpty(recipient.getLastName()) - ); + return recipientTemplateParams.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().apply(recipient))); } } diff --git a/application/src/main/java/org/thingsboard/server/service/notification/TemplateService.java b/application/src/main/java/org/thingsboard/server/service/notification/TemplateService.java new file mode 100644 index 0000000000..04840bfe77 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/notification/TemplateService.java @@ -0,0 +1,95 @@ +/** + * 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.service.notification; + +import com.google.common.base.Strings; +import lombok.SneakyThrows; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.thingsboard.script.api.ScriptType; +import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +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; + +@Service +public class TemplateService { + + private static final Pattern TEMPLATE_PARAM_PATTERN = Pattern.compile("\\$\\{(.+?)(:[a-zA-Z]+)?}"); + private static final Pattern TEMPLATE_SCRIPT_PATTERN = Pattern.compile("#\\{(.+?)}"); + + private static final Map> SHORT_FUNCTIONS = Map.of( + "upperCase", String::toUpperCase, + "lowerCase", String::toLowerCase, + "capitalize", StringUtils::capitalize + ); + + @Autowired(required = false) + private TbelInvokeService tbelInvokeService; + + public String processTemplate(TenantId tenantId, String template, Map context, Set ignoredParams) { + String result = TEMPLATE_PARAM_PATTERN.matcher(template).replaceAll(matchResult -> { + String key = matchResult.group(1); + if (!context.containsKey(key) || ignoredParams.contains(key)) { + return "\\" + matchResult.group(); + } + String value = nullToEmpty(context.get(key)); + String function = removeStart(matchResult.group(2), ":"); + if (function != null) { + if (SHORT_FUNCTIONS.containsKey(function)) { + value = SHORT_FUNCTIONS.get(function).apply(value); + } + } + return value; + }); + + if (tbelInvokeService != null) { + result = TEMPLATE_SCRIPT_PATTERN.matcher(result).replaceAll(matchResult -> { + String script = matchResult.group(1); + if (!ignoredParams.isEmpty() && ignoredParams.stream().anyMatch(script::contains)) { + return matchResult.group(); + } + return processScript(tenantId, script, context); + }); + } + + return result; + } + + @SneakyThrows + private String processScript(TenantId tenantId, String script, Map context) { + String scriptBody = "return " + script + ";"; + String[] argsNames = context.keySet().toArray(String[]::new); + Object[] args = context.values().stream().map(Strings::nullToEmpty).toArray(); + try { + UUID scriptId = tbelInvokeService.eval(tenantId, ScriptType.TEMPLATE_SCRIPT, scriptBody, argsNames).get(); + Object result = tbelInvokeService.invokeScript(tenantId, null, scriptId, args).get(); + return String.valueOf(result); + } catch (ExecutionException e) { + throw e.getCause(); + } + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java index a22143ab1c..09f0ce1c0c 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java @@ -128,6 +128,12 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest protected NotificationTemplate createNotificationTemplate(NotificationType notificationType, String subject, String text, NotificationDeliveryMethod... deliveryMethods) { + NotificationTemplate notificationTemplate = setUpNotificationTemplate(notificationType, subject, text, deliveryMethods); + return saveNotificationTemplate(notificationTemplate); + } + + protected NotificationTemplate setUpNotificationTemplate(NotificationType notificationType, String subject, + String text, NotificationDeliveryMethod... deliveryMethods) { NotificationTemplate notificationTemplate = new NotificationTemplate(); notificationTemplate.setTenantId(tenantId); notificationTemplate.setName("Notification template: " + text); @@ -161,7 +167,7 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest config.getDeliveryMethodsTemplates().put(deliveryMethod, deliveryMethodNotificationTemplate); } notificationTemplate.setConfiguration(config); - return saveNotificationTemplate(notificationTemplate); + return notificationTemplate; } protected NotificationTemplate saveNotificationTemplate(NotificationTemplate notificationTemplate) { diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java index ce42298d9f..aca5d4fca0 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java @@ -19,27 +19,47 @@ import com.fasterxml.jackson.core.type.TypeReference; import org.apache.commons.lang3.StringUtils; import org.junit.Before; import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultMatcher; +import org.thingsboard.rule.engine.api.NotificationCenter; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.IdBased; +import org.thingsboard.server.common.data.id.NotificationTargetId; +import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import org.thingsboard.server.common.data.notification.NotificationRequest; import org.thingsboard.server.common.data.notification.NotificationType; +import org.thingsboard.server.common.data.notification.info.EntityActionNotificationInfo; +import org.thingsboard.server.common.data.notification.info.NotificationInfo; import org.thingsboard.server.common.data.notification.template.EmailDeliveryMethodNotificationTemplate; import org.thingsboard.server.common.data.notification.template.NotificationTemplate; import org.thingsboard.server.common.data.notification.template.NotificationTemplateConfig; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.notification.NotificationService; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.List; import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @DaoSqlTest public class NotificationTemplateApiTest extends AbstractNotificationApiTest { + @Autowired + private NotificationCenter notificationCenter; + @Autowired + private NotificationService notificationService; + @Before public void beforeEach() throws Exception { loginTenantAdmin(); @@ -77,6 +97,59 @@ public class NotificationTemplateApiTest extends AbstractNotificationApiTest { .contains("name is malformed"); } + @Test + public void testTemplatesProcessing_tbelScripts() throws Exception { + User firstRecipient = new User(); + firstRecipient.setEmail("recipient1@tb.org"); + firstRecipient.setAuthority(Authority.TENANT_ADMIN); + firstRecipient.setFirstName("Aba"); + firstRecipient.setLastName("Duba"); + firstRecipient = createUser(firstRecipient, "123456"); + User secondRecipient = new User(); + secondRecipient.setEmail("recipient2@tb.org"); + secondRecipient.setAuthority(Authority.TENANT_ADMIN); + secondRecipient = createUser(secondRecipient, "123456"); + NotificationTargetId target = createNotificationTarget(firstRecipient.getId(), secondRecipient.getId()).getId(); + + String template = "Hi, #{ recipientFirstName.isEmpty() ? recipientEmail : (recipientFirstName + ' ' + recipientLastName) } #{ '!!!' }"; + process(template, NotificationType.GENERAL, null, target); + assertThat(getLastNotification(firstRecipient).getText()).isEqualTo("Hi, Aba Duba !!!"); + assertThat(getLastNotification(secondRecipient).getText()).isEqualTo("Hi, recipient2@tb.org !!!"); + + NotificationInfo info = EntityActionNotificationInfo.builder() + .entityId(new DeviceId(UUID.randomUUID())) + .entityName("device A") + .actionType(ActionType.DELETED) + .userId(tenantAdminUserId.getId()) + .build(); + template = "Hi, #{ recipientTitle } (${recipientEmail}). Someone ${actionType} your ${entityName}" + + "#{ actionType == 'deleted' ? '!!!' : '.' } #{ ':(' }"; + process(template, NotificationType.ENTITY_ACTION, info, target); + assertThat(getLastNotification(firstRecipient).getText()).isEqualTo("Hi, Aba Duba (recipient1@tb.org). " + + "Someone deleted your device A!!! :("); + assertThat(getLastNotification(secondRecipient).getText()).isEqualTo("Hi, recipient2@tb.org (recipient2@tb.org). " + + "Someone deleted your device A!!! :("); + } + + private void process(String text, NotificationType notificationType, NotificationInfo info, NotificationTargetId target) { + NotificationTemplate template = setUpNotificationTemplate(notificationType, "Test", text, NotificationDeliveryMethod.WEB); + NotificationRequest request = NotificationRequest.builder() + .tenantId(tenantId) + .targets(List.of(target.getId())) + .template(template) + .info(info) + .originatorEntityId(tenantAdminUserId) + .build(); + notificationCenter.processNotificationRequest(tenantId, request, null); + } + + private Notification getLastNotification(User recipient) { + return await().atMost(30, TimeUnit.SECONDS) + .until(() -> notificationService.findLatestUnreadNotificationsByRecipientId(tenantId, recipient.getId(), 1).getData(), + notifications -> !notifications.isEmpty()) + .get(0); + } + @Test public void testTemplatesSearch() throws Exception { NotificationTemplate alarmNotificationTemplate = createNotificationTemplate(NotificationType.ALARM, "Alarm", "Alarm", NotificationDeliveryMethod.WEB); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/TemplateUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/TemplateUtils.java deleted file mode 100644 index 9ffe9189e4..0000000000 --- a/common/data/src/main/java/org/thingsboard/server/common/data/util/TemplateUtils.java +++ /dev/null @@ -1,56 +0,0 @@ -/** - * 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> FUNCTIONS = Map.of( - "upperCase", String::toUpperCase, - "lowerCase", String::toLowerCase, - "capitalize", StringUtils::capitalize - ); - - private TemplateUtils() {} - - public static String processTemplate(String template, Map 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; - }); - } - -} diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptType.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptType.java index 3da6543a43..ab766e2aa9 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptType.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptType.java @@ -16,5 +16,6 @@ package org.thingsboard.script.api; public enum ScriptType { - RULE_NODE_SCRIPT + RULE_NODE_SCRIPT, + TEMPLATE_SCRIPT }