From 649b5d640c83445a28091b33b74e93d5076cf5ab Mon Sep 17 00:00:00 2001 From: guqing Date: Mon, 15 Apr 2024 11:26:36 +0800 Subject: [PATCH] feat: subscription support for expression-based subscribing --- .../extension/notification/Subscription.java | 5 +- .../CommentNotificationReasonPublisher.java | 31 +-- .../ReplyNotificationSubscriptionHelper.java | 45 ++--- .../extension/reconciler/PostReconciler.java | 7 +- .../reconciler/SinglePageReconciler.java | 7 +- .../extension/reconciler/UserReconciler.java | 47 +++++ .../run/halo/app/infra/SchemeInitializer.java | 5 +- .../DefaultNotificationCenter.java | 149 +++++--------- .../DefaultSubscriberEmailResolver.java | 23 +-- .../app/notification/RecipientResolver.java | 9 + .../notification/RecipientResolverImpl.java | 145 +++++++++++++ .../run/halo/app/notification/Subscriber.java | 28 +++ .../resources/extensions/notification.yaml | 12 ++ ...ommentNotificationReasonPublisherTest.java | 15 -- ...plyNotificationSubscriptionHelperTest.java | 71 +++---- .../reconciler/PostReconcilerTest.java | 8 +- .../reconciler/SinglePageReconcilerTest.java | 6 +- .../reconciler/UserReconcilerTest.java | 6 + .../DefaultNotificationCenterTest.java | 135 +++---------- .../RecipientResolverImplTest.java | 191 ++++++++++++++++++ docs/notification/README.md | 106 ++++++---- 21 files changed, 651 insertions(+), 400 deletions(-) create mode 100644 application/src/main/java/run/halo/app/notification/RecipientResolver.java create mode 100644 application/src/main/java/run/halo/app/notification/RecipientResolverImpl.java create mode 100644 application/src/main/java/run/halo/app/notification/Subscriber.java create mode 100644 application/src/test/java/run/halo/app/notification/RecipientResolverImplTest.java diff --git a/api/src/main/java/run/halo/app/core/extension/notification/Subscription.java b/api/src/main/java/run/halo/app/core/extension/notification/Subscription.java index f2e998ab94d..f1ad278ea95 100644 --- a/api/src/main/java/run/halo/app/core/extension/notification/Subscription.java +++ b/api/src/main/java/run/halo/app/core/extension/notification/Subscription.java @@ -56,9 +56,12 @@ public static class InterestReason { + "interested in") private String reasonType; - @Schema(requiredMode = REQUIRED, description = "The subject name of reason type to be" + @Schema(requiredMode = NOT_REQUIRED, description = "The subject name of reason type to be" + " interested in") private ReasonSubject subject; + + @Schema(requiredMode = NOT_REQUIRED, description = "The expression to be interested in") + private String expression; } @Data diff --git a/application/src/main/java/run/halo/app/content/comment/CommentNotificationReasonPublisher.java b/application/src/main/java/run/halo/app/content/comment/CommentNotificationReasonPublisher.java index 07f61c0e479..8af96c59773 100644 --- a/application/src/main/java/run/halo/app/content/comment/CommentNotificationReasonPublisher.java +++ b/application/src/main/java/run/halo/app/content/comment/CommentNotificationReasonPublisher.java @@ -1,6 +1,7 @@ package run.halo.app.content.comment; import static org.apache.commons.lang3.StringUtils.defaultIfBlank; +import static run.halo.app.content.comment.ReplyNotificationSubscriptionHelper.identityFrom; import com.fasterxml.jackson.core.type.TypeReference; import java.util.Map; @@ -29,7 +30,6 @@ import run.halo.app.infra.ExternalLinkProcessor; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.notification.NotificationReasonEmitter; -import run.halo.app.notification.UserIdentity; import run.halo.app.plugin.ExtensionComponentsFinder; import run.halo.app.plugin.extensionpoint.ExtensionGetter; @@ -114,6 +114,7 @@ public void publishReasonBy(Comment comment) { builder -> { var attributes = CommentOnPostReasonData.builder() .postName(subjectRef.getName()) + .postOwner(post.getSpec().getOwner()) .postTitle(post.getSpec().getTitle()) .postUrl(postUrl) .commenter(owner.getDisplayName()) @@ -144,8 +145,9 @@ boolean isPostOwner(Post post, Comment.CommentOwner commentOwner) { } @Builder - record CommentOnPostReasonData(String postName, String postTitle, String postUrl, - String commenter, String content, String commentName) { + record CommentOnPostReasonData(String postName, String postOwner, String postTitle, + String postUrl, String commenter, String content, + String commentName) { } } @@ -180,6 +182,7 @@ public void publishReasonBy(Comment comment) { builder -> { var attributes = CommentOnPageReasonData.builder() .pageName(subjectRef.getName()) + .pageOwner(singlePage.getSpec().getOwner()) .pageTitle(singlePage.getSpec().getTitle()) .pageUrl(pageUrl) .commenter(defaultIfBlank(owner.getDisplayName(), owner.getName())) @@ -210,8 +213,9 @@ boolean isPageOwner(SinglePage page, Comment.CommentOwner commentOwner) { } @Builder - record CommentOnPageReasonData(String pageName, String pageTitle, String pageUrl, - String commenter, String content, String commentName) { + record CommentOnPageReasonData(String pageName, String pageOwner, String pageTitle, + String pageUrl, String commenter, String content, + String commentName) { } } @@ -224,13 +228,6 @@ public static Map toAttributeMap(T data) { } } - static UserIdentity identityFrom(Comment.CommentOwner owner) { - if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) { - return UserIdentity.anonymousWithEmail(owner.getName()); - } - return UserIdentity.of(owner.getName()); - } - @Component @RequiredArgsConstructor static class NewReplyReasonPublisher { @@ -272,6 +269,10 @@ public void publishReasonBy(Reply reply, Comment comment) { .orElse(null); var replyOwner = reply.getSpec().getOwner(); + var repliedOwner = quoteReplyOptional + .map(quoteReply -> quoteReply.getSpec().getOwner()) + .orElseGet(() -> comment.getSpec().getOwner()); + var reasonAttributesBuilder = NewReplyReasonData.builder() .commentContent(comment.getSpec().getContent()) .isQuoteReply(isQuoteReply) @@ -279,7 +280,9 @@ public void publishReasonBy(Reply reply, Comment comment) { .commentName(comment.getMetadata().getName()) .replier(defaultIfBlank(replyOwner.getDisplayName(), replyOwner.getName())) .content(reply.getSpec().getContent()) - .replyName(reply.getMetadata().getName()); + .replyName(reply.getMetadata().getName()) + .replyOwner(identityFrom(replyOwner).name()) + .repliedOwner(identityFrom(repliedOwner).name()); getCommentSubjectDisplay(comment.getSpec().getSubjectRef()) .ifPresent(subject -> { @@ -337,7 +340,7 @@ record NewReplyReasonData(String commentContent, String commentSubjectTitle, String commentSubjectUrl, boolean isQuoteReply, String quoteContent, String commentName, String replier, String content, - String replyName) { + String replyName, String replyOwner, String repliedOwner) { } } } diff --git a/application/src/main/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelper.java b/application/src/main/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelper.java index 38879160b79..eff83af369b 100644 --- a/application/src/main/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelper.java +++ b/application/src/main/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelper.java @@ -9,7 +9,7 @@ import run.halo.app.core.extension.content.Reply; import run.halo.app.core.extension.notification.Subscription; import run.halo.app.notification.NotificationCenter; -import run.halo.app.notification.SubscriberEmailResolver; +import run.halo.app.notification.UserIdentity; /** * Reply notification subscription helper. @@ -22,7 +22,6 @@ public class ReplyNotificationSubscriptionHelper { private final NotificationCenter notificationCenter; - private final SubscriberEmailResolver subscriberEmailResolver; /** * Subscribe new reply reason for comment. @@ -30,13 +29,7 @@ public class ReplyNotificationSubscriptionHelper { * @param comment comment */ public void subscribeNewReplyReasonForComment(Comment comment) { - var reasonSubject = Subscription.ReasonSubject.builder() - .apiVersion(comment.getApiVersion()) - .kind(comment.getKind()) - .name(comment.getMetadata().getName()) - .build(); - subscribeReply(reasonSubject, - Identity.fromCommentOwner(comment.getSpec().getOwner())); + subscribeReply(identityFrom(comment.getSpec().getOwner())); } /** @@ -45,50 +38,36 @@ public void subscribeNewReplyReasonForComment(Comment comment) { * @param reply reply */ public void subscribeNewReplyReasonForReply(Reply reply) { - var reasonSubject = Subscription.ReasonSubject.builder() - .apiVersion(reply.getApiVersion()) - .kind(reply.getKind()) - .name(reply.getMetadata().getName()) - .build(); var subjectOwner = reply.getSpec().getOwner(); - subscribeReply(reasonSubject, - Identity.fromCommentOwner(subjectOwner)); + subscribeReply(identityFrom(subjectOwner)); } - void subscribeReply(Subscription.ReasonSubject reasonSubject, - Identity identity) { + void subscribeReply(UserIdentity identity) { var subscriber = createSubscriber(identity); if (subscriber == null) { return; } var interestReason = new Subscription.InterestReason(); interestReason.setReasonType(NotificationReasonConst.SOMEONE_REPLIED_TO_YOU); - interestReason.setSubject(reasonSubject); + interestReason.setExpression("props.repliedOwner == '%s'".formatted(identity.name())); notificationCenter.subscribe(subscriber, interestReason).block(); } @Nullable - private Subscription.Subscriber createSubscriber(Identity author) { + private Subscription.Subscriber createSubscriber(UserIdentity author) { if (StringUtils.isBlank(author.name())) { return null; } - Subscription.Subscriber subscriber; - if (author.isEmail()) { - subscriber = subscriberEmailResolver.ofEmail(author.name()); - } else { - subscriber = new Subscription.Subscriber(); - subscriber.setName(author.name()); - } + Subscription.Subscriber subscriber = new Subscription.Subscriber(); + subscriber.setName(author.name()); return subscriber; } - record Identity(String name, boolean isEmail) { - public static Identity fromCommentOwner(Comment.CommentOwner commentOwner) { - if (Comment.CommentOwner.KIND_EMAIL.equals(commentOwner.getKind())) { - return new Identity(commentOwner.getName(), true); - } - return new Identity(commentOwner.getName(), false); + public static UserIdentity identityFrom(Comment.CommentOwner owner) { + if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) { + return UserIdentity.anonymousWithEmail(owner.getName()); } + return UserIdentity.of(owner.getName()); } } diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java index 90b01017a4d..6b464d784f5 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java @@ -246,11 +246,8 @@ void subscribeNewCommentNotification(Post post) { var interestReason = new Subscription.InterestReason(); interestReason.setReasonType(NotificationReasonConst.NEW_COMMENT_ON_POST); - interestReason.setSubject(Subscription.ReasonSubject.builder() - .apiVersion(post.getApiVersion()) - .kind(post.getKind()) - .name(post.getMetadata().getName()) - .build()); + interestReason.setExpression( + "props.postOwner == '%s'".formatted(post.getSpec().getOwner())); notificationCenter.subscribe(subscriber, interestReason).block(); } diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java index 4ceecac67c1..08bd37a26ca 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java @@ -107,11 +107,8 @@ void subscribeNewCommentNotification(SinglePage page) { var interestReason = new Subscription.InterestReason(); interestReason.setReasonType(NotificationReasonConst.NEW_COMMENT_ON_PAGE); - interestReason.setSubject(Subscription.ReasonSubject.builder() - .apiVersion(page.getApiVersion()) - .kind(page.getKind()) - .name(page.getMetadata().getName()) - .build()); + interestReason.setExpression( + "props.pageOwner == '%s'".formatted(page.getSpec().getOwner())); notificationCenter.subscribe(subscriber, interestReason).block(); } diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java index 87d93e5040b..5a66d933bcf 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java @@ -13,15 +13,23 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.retry.support.RetryTemplate; import org.springframework.stereotype.Component; +import run.halo.app.content.NotificationReasonConst; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.User; import run.halo.app.core.extension.UserConnection; import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.core.extension.content.Comment; +import run.halo.app.core.extension.content.Post; +import run.halo.app.core.extension.content.Reply; +import run.halo.app.core.extension.content.SinglePage; +import run.halo.app.core.extension.notification.Subscription; import run.halo.app.core.extension.service.AttachmentService; import run.halo.app.core.extension.service.RoleService; +import run.halo.app.extension.Extension; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.GroupKind; +import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; @@ -32,6 +40,7 @@ import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.PathUtils; +import run.halo.app.notification.NotificationCenter; @Slf4j @Component @@ -42,6 +51,7 @@ public class UserReconciler implements Reconciler { private final ExternalUrlSupplier externalUrlSupplier; private final RoleService roleService; private final AttachmentService attachmentService; + private final NotificationCenter notificationCenter; private final RetryTemplate retryTemplate = RetryTemplate.builder() .maxAttempts(20) .fixedBackoff(300) @@ -60,6 +70,9 @@ public Result reconcile(Request request) { ensureRoleNamesAnno(request.name()); updatePermalink(request.name()); handleAvatar(request.name()); + + // cleanup history data and remove this line in the future + removeInternalSubscription(request.name()); }); return new Result(false, null); } @@ -104,6 +117,40 @@ private void handleAvatar(String name) { }); } + private void removeInternalSubscription(String username) { + var subscriber = new Subscription.Subscriber(); + subscriber.setName(username); + + var commentOnPost = + createInterestReason(NotificationReasonConst.NEW_COMMENT_ON_POST, Post.class); + notificationCenter.unsubscribe(subscriber, commentOnPost).block(); + + var commentOnPage = + createInterestReason(NotificationReasonConst.NEW_COMMENT_ON_PAGE, SinglePage.class); + notificationCenter.unsubscribe(subscriber, commentOnPage).block(); + + var replyOnComment = + createInterestReason(NotificationReasonConst.SOMEONE_REPLIED_TO_YOU, Comment.class); + notificationCenter.unsubscribe(subscriber, replyOnComment).block(); + + var replyOnReply = + createInterestReason(NotificationReasonConst.SOMEONE_REPLIED_TO_YOU, Reply.class); + notificationCenter.unsubscribe(subscriber, replyOnReply).block(); + } + + Subscription.InterestReason createInterestReason(String type, + Class extensionClass) { + var interestReason = new Subscription.InterestReason(); + interestReason.setReasonType(type); + + var reasonSubject = new Subscription.ReasonSubject(); + var gvk = GroupVersionKind.fromExtension(extensionClass); + reasonSubject.setKind(gvk.kind()); + reasonSubject.setApiVersion(gvk.groupVersion().toString()); + interestReason.setSubject(reasonSubject); + return interestReason; + } + private void ensureRoleNamesAnno(String name) { client.fetch(User.class, name).ifPresent(user -> { Map annotations = MetadataUtil.nullSafeAnnotations(user); diff --git a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java index c5fdf462bf3..b77fa4ac60a 100644 --- a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -406,7 +406,10 @@ public void onApplicationEvent(@NonNull ApplicationContextInitializedEvent event indexSpecs.add(new IndexSpec() .setName("spec.reason.subject") .setIndexFunc(simpleAttribute(Subscription.class, - subscription -> subscription.getSpec().getReason().getSubject().toString())) + subscription -> { + var subject = subscription.getSpec().getReason().getSubject(); + return subject == null ? null : subject.toString(); + })) ); indexSpecs.add(new IndexSpec() .setName("spec.subscriber") diff --git a/application/src/main/java/run/halo/app/notification/DefaultNotificationCenter.java b/application/src/main/java/run/halo/app/notification/DefaultNotificationCenter.java index eb59f22d502..67f54fb3374 100644 --- a/application/src/main/java/run/halo/app/notification/DefaultNotificationCenter.java +++ b/application/src/main/java/run/halo/app/notification/DefaultNotificationCenter.java @@ -6,15 +6,11 @@ import static run.halo.app.extension.index.query.QueryFactory.or; import java.util.HashMap; -import java.util.HashSet; import java.util.Locale; import java.util.Optional; -import java.util.function.BiPredicate; -import java.util.function.Function; import lombok.Builder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import org.springframework.util.Assert; @@ -27,7 +23,6 @@ import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.ReasonType; import run.halo.app.core.extension.notification.Subscription; -import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; @@ -55,22 +50,17 @@ public class DefaultNotificationCenter implements NotificationCenter { private final UserNotificationPreferenceService userNotificationPreferenceService; private final NotificationTemplateRender notificationTemplateRender; private final SubscriptionRouter subscriptionRouter; + private final RecipientResolver recipientResolver; @Override public Mono notify(Reason reason) { - var reasonSubject = reason.getSpec().getSubject(); - var subscriptionReasonSubject = Subscription.ReasonSubject.builder() - .apiVersion(reasonSubject.getApiVersion()) - .kind(reasonSubject.getKind()) - .name(reasonSubject.getName()) - .build(); - return listObservers(reason.getSpec().getReasonType(), subscriptionReasonSubject) - .doOnNext(subscription -> { + return recipientResolver.resolve(reason) + .doOnNext(subscriber -> { log.debug("Dispatching notification to subscriber [{}] for reason [{}]", - subscription.getSpec().getSubscriber(), reason.getMetadata().getName()); + subscriber, reason.getMetadata().getName()); }) .publishOn(Schedulers.boundedElastic()) - .flatMap(subscription -> dispatchNotification(reason, subscription)) + .flatMap(subscriber -> dispatchNotification(reason, subscriber)) .then(); } @@ -125,43 +115,49 @@ Mono> pageSubscriptionBy(Subscription.Subscriber subscr Flux listSubscription(Subscription.Subscriber subscriber, Subscription.InterestReason reason) { var listOptions = new ListOptions(); - var fieldQuery = and( - getSubscriptionFieldQuery(reason.getReasonType(), reason.getSubject()), - equal("spec.subscriber", subscriber.getName()) - ); + var fieldQuery = equal("spec.subscriber", subscriber.getName()); + if (reason.getSubject() != null) { + fieldQuery = and( + fieldQuery, + getSubscriptionFieldQuery(reason.getReasonType(), reason.getSubject()) + ); + } listOptions.setFieldSelector(FieldSelector.of(fieldQuery)); return client.listAll(Subscription.class, listOptions, defaultSort()); } - Flux getNotifiersBySubscriber(Subscription.Subscriber subscriber, Reason reason) { + Flux getNotifiersBySubscriber(Subscriber subscriber, Reason reason) { var reasonType = reason.getSpec().getReasonType(); - return userNotificationPreferenceService.getByUser(subscriber.getName()) + return userNotificationPreferenceService.getByUser(subscriber.name()) .map(UserNotificationPreference::getReasonTypeNotifier) .map(reasonTypeNotification -> reasonTypeNotification.getNotifiers(reasonType)) .flatMapMany(Flux::fromIterable); } - Mono dispatchNotification(Reason reason, Subscription subscription) { - var subscriber = subscription.getSpec().getSubscriber(); + Mono dispatchNotification(Reason reason, Subscriber subscriber) { return getNotifiersBySubscriber(subscriber, reason) .flatMap(notifierName -> client.fetch(NotifierDescriptor.class, notifierName)) - .flatMap(descriptor -> prepareNotificationElement(subscription, reason, descriptor)) + .flatMap(descriptor -> prepareNotificationElement(subscriber, reason, descriptor)) .flatMap(element -> { var dispatchMono = sendNotification(element); + if (subscriber.isAnonymous()) { + return dispatchMono; + } + // create notification for user var innerNofificationMono = createNotification(element); return Mono.when(dispatchMono, innerNofificationMono); }) .then(); } - Mono prepareNotificationElement(Subscription subscription, Reason reason, + Mono prepareNotificationElement(Subscriber subscriber, Reason reason, NotifierDescriptor descriptor) { - return getLocaleFromSubscriber(subscription) - .flatMap(locale -> inferenceTemplate(reason, subscription, locale)) + return getLocaleFromSubscriber(subscriber) + .flatMap(locale -> inferenceTemplate(reason, subscriber, locale)) .map(notificationContent -> NotificationElement.builder() .descriptor(descriptor) .reason(reason) - .subscription(subscription) + .subscriber(subscriber) .reasonType(notificationContent.reasonType()) .notificationTitle(notificationContent.title()) .reasonAttributes(notificationContent.reasonAttributes()) @@ -173,7 +169,7 @@ Mono prepareNotificationElement(Subscription subscription, Mono sendNotification(NotificationElement notificationElement) { var descriptor = notificationElement.descriptor(); - var subscription = notificationElement.subscription(); + var subscriber = notificationElement.subscriber(); final var notifierExtName = descriptor.getSpec().getNotifierExtName(); return notificationContextFrom(notificationElement) .flatMap(notificationContext -> notificationSender.sendNotification(notifierExtName, @@ -181,7 +177,7 @@ Mono sendNotification(NotificationElement notificationElement) { .onErrorResume(throwable -> { log.error( "Failed to send notification to subscriber [{}] through notifier [{}]", - subscription.getSpec().getSubscriber(), + subscriber, descriptor.getSpec().getDisplayName(), throwable); return Mono.empty(); @@ -192,9 +188,8 @@ Mono sendNotification(NotificationElement notificationElement) { Mono createNotification(NotificationElement notificationElement) { var reason = notificationElement.reason(); - var subscription = notificationElement.subscription(); - var subscriber = subscription.getSpec().getSubscriber(); - return client.fetch(User.class, subscriber.getName()) + var subscriber = notificationElement.subscriber(); + return client.fetch(User.class, subscriber.name()) .flatMap(user -> { Notification notification = new Notification(); notification.setMetadata(new Metadata()); @@ -203,7 +198,7 @@ Mono createNotification(NotificationElement notificationElement) { notification.getSpec().setTitle(notificationElement.notificationTitle()); notification.getSpec().setRawContent(notificationElement.notificationRawBody()); notification.getSpec().setHtmlContent(notificationElement.notificationHtmlBody); - notification.getSpec().setRecipient(subscriber.getName()); + notification.getSpec().setRecipient(subscriber.name()); notification.getSpec().setReason(reason.getMetadata().getName()); notification.getSpec().setUnread(true); return client.create(notification); @@ -223,7 +218,7 @@ Mono notificationContextFrom(NotificationElement element) { final var descriptorName = element.descriptor().getMetadata().getName(); final var reason = element.reason(); final var descriptor = element.descriptor(); - final var subscription = element.subscription(); + final var subscriber = element.subscriber(); var messagePayload = new NotificationContext.MessagePayload(); messagePayload.setTitle(element.notificationTitle()); @@ -232,7 +227,7 @@ Mono notificationContextFrom(NotificationElement element) { messagePayload.setAttributes(element.reasonAttributes()); var message = new NotificationContext.Message(); - message.setRecipient(subscription.getSpec().getSubscriber().getName()); + message.setRecipient(subscriber.name()); message.setPayload(messagePayload); message.setTimestamp(reason.getMetadata().getCreationTimestamp()); var reasonSubject = reason.getSpec().getSubject(); @@ -270,25 +265,25 @@ Mono notificationContextFrom(NotificationElement element) { }); } - Mono inferenceTemplate(Reason reason, Subscription subscription, + Mono inferenceTemplate(Reason reason, Subscriber subscriber, Locale locale) { var reasonTypeName = reason.getSpec().getReasonType(); - var subscriber = subscription.getSpec().getSubscriber(); return getReasonType(reasonTypeName) .flatMap(reasonType -> notificationTemplateSelector.select(reasonTypeName, locale) .flatMap(template -> { final var templateContent = template.getSpec().getTemplate(); var model = toReasonAttributes(reason); - var identity = UserIdentity.of(subscriber.getName()); var subscriberInfo = new HashMap<>(); - if (identity.isAnonymous()) { - subscriberInfo.put("displayName", identity.getEmail().orElse("")); + if (subscriber.isAnonymous()) { + subscriberInfo.put("displayName", subscriber.getEmail().orElseThrow()); } else { - subscriberInfo.put("displayName", "@" + identity.name()); + subscriberInfo.put("displayName", "@" + subscriber.username()); } - subscriberInfo.put("id", subscriber.getName()); + subscriberInfo.put("id", subscriber.name()); model.put("subscriber", subscriberInfo); - model.put("unsubscribeUrl", getUnsubscribeUrl(subscription)); + + var unsubscriptionMono = getUnsubscribeUrl(subscriber.subscriptionName()) + .doOnNext(url -> model.put("unsubscribeUrl", url)); var builder = NotificationContent.builder() .reasonType(reasonType) @@ -305,7 +300,7 @@ Mono inferenceTemplate(Reason reason, Subscription subscrip var htmlBodyMono = notificationTemplateRender .render(templateContent.getHtmlBody(), model) .doOnNext(builder::htmlBody); - return Mono.when(titleMono, rawBodyMono, htmlBodyMono) + return Mono.when(unsubscriptionMono, titleMono, rawBodyMono, htmlBodyMono) .then(Mono.fromSupplier(builder::build)); }) ); @@ -316,13 +311,14 @@ record NotificationContent(String title, String rawBody, String htmlBody, Reason ReasonAttributes reasonAttributes) { } - String getUnsubscribeUrl(Subscription subscription) { - return subscriptionRouter.getUnsubscribeUrl(subscription); + Mono getUnsubscribeUrl(String subscriptionName) { + return client.get(Subscription.class, subscriptionName) + .map(subscriptionRouter::getUnsubscribeUrl); } @Builder record NotificationElement(ReasonType reasonType, Reason reason, - Subscription subscription, NotifierDescriptor descriptor, + Subscriber subscriber, NotifierDescriptor descriptor, String notificationTitle, String notificationRawBody, String notificationHtmlBody, @@ -333,23 +329,14 @@ Mono getReasonType(String reasonTypeName) { return client.get(ReasonType.class, reasonTypeName); } - Mono getLocaleFromSubscriber(Subscription subscription) { + Mono getLocaleFromSubscriber(Subscriber subscriber) { // TODO get locale from subscriber return Mono.just(Locale.getDefault()); } - Flux listObservers(String reasonTypeName, - Subscription.ReasonSubject reasonSubject) { - Assert.notNull(reasonTypeName, "The reasonTypeName must not be null"); - Assert.notNull(reasonSubject, "The reasonSubject must not be null"); - final var listOptions = new ListOptions(); - var fieldQuery = getSubscriptionFieldQuery(reasonTypeName, reasonSubject); - listOptions.setFieldSelector(FieldSelector.of(fieldQuery)); - return distinctByKey(client.listAll(Subscription.class, listOptions, defaultSort())); - } - private static Query getSubscriptionFieldQuery(String reasonTypeName, Subscription.ReasonSubject reasonSubject) { + Assert.notNull(reasonSubject, "The reasonSubject must not be null"); var matchAllSubject = new Subscription.ReasonSubject(); matchAllSubject.setKind(reasonSubject.getKind()); matchAllSubject.setApiVersion(reasonSubject.getApiVersion()); @@ -361,52 +348,8 @@ private static Query getSubscriptionFieldQuery(String reasonTypeName, ); } - static Flux distinctByKey(Flux source) { - final var distinctKeyPredicate = subscriptionDistinctKeyPredicate(); - return source.distinct(Function.identity(), HashSet::new, - (set, val) -> { - for (Subscription subscription : set) { - if (distinctKeyPredicate.test(subscription, val)) { - return false; - } - } - // no duplicated return true - set.add(val); - return true; - }, - HashSet::clear); - } - Sort defaultSort() { return Sort.by(Sort.Order.asc("metadata.creationTimestamp"), Sort.Order.asc("metadata.name")); } - - static BiPredicate subscriptionDistinctKeyPredicate() { - return (a, b) -> { - if (!a.getSpec().getSubscriber().equals(b.getSpec().getSubscriber())) { - return false; - } - var reasonA = a.getSpec().getReason(); - var reasonB = b.getSpec().getReason(); - if (!reasonA.getReasonType().equals(reasonB.getReasonType())) { - return false; - } - var ars = reasonA.getSubject(); - var brs = reasonB.getSubject(); - var gvkForA = - GroupVersionKind.fromAPIVersionAndKind(ars.getApiVersion(), ars.getKind()); - var gvkForB = - GroupVersionKind.fromAPIVersionAndKind(brs.getApiVersion(), brs.getKind()); - - if (!gvkForA.groupKind().equals(gvkForB.groupKind())) { - return false; - } - // name is blank present match all - if (StringUtils.isBlank(ars.getName()) || StringUtils.isBlank(brs.getName())) { - return true; - } - return ars.getName().equals(brs.getName()); - }; - } } diff --git a/application/src/main/java/run/halo/app/notification/DefaultSubscriberEmailResolver.java b/application/src/main/java/run/halo/app/notification/DefaultSubscriberEmailResolver.java index 44dc0a22fe0..bc86dcdc150 100644 --- a/application/src/main/java/run/halo/app/notification/DefaultSubscriberEmailResolver.java +++ b/application/src/main/java/run/halo/app/notification/DefaultSubscriberEmailResolver.java @@ -21,13 +21,12 @@ @Component @RequiredArgsConstructor public class DefaultSubscriberEmailResolver implements SubscriberEmailResolver { - private static final String SEPARATOR = "#"; - private final ReactiveExtensionClient client; @Override public Mono resolve(Subscription.Subscriber subscriber) { - if (isEmailSubscriber(subscriber)) { + var identity = UserIdentity.of(subscriber.getName()); + if (identity.isAnonymous()) { return Mono.fromSupplier(() -> getEmail(subscriber)); } return client.fetch(User.class, subscriber.getName()) @@ -44,20 +43,14 @@ public Subscription.Subscriber ofEmail(String email) { return subscriber; } - static boolean isEmailSubscriber(Subscription.Subscriber subscriber) { - return UserIdentity.of(subscriber.getName()).isAnonymous(); - } - @NonNull String getEmail(Subscription.Subscriber subscriber) { - if (!isEmailSubscriber(subscriber)) { - throw new IllegalStateException("The subscriber is not an email subscriber"); - } - var subscriberName = subscriber.getName(); - String email = subscriberName.substring(subscriberName.indexOf(SEPARATOR) + 1); - if (StringUtils.isBlank(email)) { - throw new IllegalStateException("The subscriber does not have an email"); + var identity = UserIdentity.of(subscriber.getName()); + if (!identity.isAnonymous()) { + throw new IllegalStateException("The subscriber is not an anonymous subscriber"); } - return email; + return identity.getEmail() + .filter(StringUtils::isNotBlank) + .orElseThrow(() -> new IllegalStateException("The subscriber does not have an email")); } } diff --git a/application/src/main/java/run/halo/app/notification/RecipientResolver.java b/application/src/main/java/run/halo/app/notification/RecipientResolver.java new file mode 100644 index 00000000000..7b059e66d09 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/RecipientResolver.java @@ -0,0 +1,9 @@ +package run.halo.app.notification; + +import reactor.core.publisher.Flux; +import run.halo.app.core.extension.notification.Reason; + +public interface RecipientResolver { + + Flux resolve(Reason reason); +} diff --git a/application/src/main/java/run/halo/app/notification/RecipientResolverImpl.java b/application/src/main/java/run/halo/app/notification/RecipientResolverImpl.java new file mode 100644 index 00000000000..9f99ff03d69 --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/RecipientResolverImpl.java @@ -0,0 +1,145 @@ +package run.halo.app.notification; + +import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; +import static run.halo.app.extension.index.query.QueryFactory.equal; + +import com.google.common.base.Throwables; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.expression.MapAccessor; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.data.domain.Sort; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.EvaluationException; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.ParseException; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.DataBindingPropertyAccessor; +import org.springframework.expression.spel.support.SimpleEvaluationContext; +import org.springframework.integration.json.JsonPropertyAccessor; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.notification.Reason; +import run.halo.app.core.extension.notification.Subscription; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.PageRequest; +import run.halo.app.extension.PageRequestImpl; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.router.selector.FieldSelector; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RecipientResolverImpl implements RecipientResolver { + private final ExpressionParser expressionParser = new SpelExpressionParser(); + private final EvaluationContext evaluationContext = createEvaluationContext(); + + private final ReactiveExtensionClient client; + + @Override + public Flux resolve(Reason reason) { + var reasonType = reason.getSpec().getReasonType(); + return listSubscriptions(reasonType) + .filter(subscription -> { + var interestReason = subscription.getSpec().getReason(); + if (interestReason.getSubject() != null) { + return subjectMatch(subscription, reason.getSpec().getSubject()); + } else if (StringUtils.isNotBlank(interestReason.getExpression())) { + return expressionMatch(subscription.getMetadata().getName(), + interestReason.getExpression(), reason); + } + return false; + }) + .map(subscription -> { + var id = UserIdentity.of(subscription.getSpec().getSubscriber().getName()); + return new Subscriber(id, subscription.getMetadata().getName()); + }) + .distinct(Subscriber::name); + } + + boolean expressionMatch(String subscriptionName, String expressionStr, Reason reason) { + try { + Expression expression = + expressionParser.parseExpression(expressionStr); + var result = expression.getValue(evaluationContext, + exprRootObject(reason), + Boolean.class); + return BooleanUtils.isTrue(result); + } catch (ParseException | EvaluationException e) { + log.debug("Failed to parse or evaluate expression for subscription [{}], skip it.", + subscriptionName, Throwables.getRootCause(e)); + return false; + } + } + + Map exprRootObject(Reason reason) { + var map = new HashMap(3, 1); + map.put("props", defaultIfNull(reason.getSpec().getAttributes(), new ReasonAttributes())); + map.put("subject", reason.getSpec().getSubject()); + map.put("author", reason.getSpec().getAuthor()); + return Collections.unmodifiableMap(map); + } + + static boolean subjectMatch(Subscription subscription, Reason.Subject reasonSubject) { + Assert.notNull(subscription, "The subscription must not be null"); + Assert.notNull(reasonSubject, "The reasonSubject must not be null"); + final var sourceSubject = subscription.getSpec().getReason().getSubject(); + + var matchSubject = new Subscription.ReasonSubject(); + matchSubject.setKind(reasonSubject.getKind()); + matchSubject.setApiVersion(reasonSubject.getApiVersion()); + + if (StringUtils.isBlank(sourceSubject.getName())) { + return sourceSubject.equals(matchSubject); + } + matchSubject.setName(reasonSubject.getName()); + return sourceSubject.equals(matchSubject); + } + + Flux listSubscriptions(String reasonType) { + var pageRequest = PageRequestImpl.of(1, 200, defaultSort()); + return Flux.defer(() -> listSubscriptions(reasonType, pageRequest)) + .expand(page -> page.hasNext() + ? listSubscriptions(reasonType, pageRequest) + : Mono.empty() + ) + .flatMap(page -> Flux.fromIterable(page.getItems())) + .filter(this::isNotDisabled); + } + + private Mono> listSubscriptions(String reasonType, + PageRequest pageRequest) { + final var listOptions = new ListOptions(); + var fieldQuery = equal("spec.reason.reasonType", reasonType); + listOptions.setFieldSelector(FieldSelector.of(fieldQuery)); + return client.listBy(Subscription.class, listOptions, pageRequest); + } + + boolean isNotDisabled(Subscription subscription) { + return !subscription.getSpec().isDisabled(); + } + + Sort defaultSort() { + return Sort.by(Sort.Order.asc("metadata.creationTimestamp"), + Sort.Order.asc("metadata.name")); + } + + EvaluationContext createEvaluationContext() { + return SimpleEvaluationContext.forPropertyAccessors( + DataBindingPropertyAccessor.forReadOnlyAccess(), + new MapAccessor(), + new JsonPropertyAccessor() + ) + .withConversionService(DefaultConversionService.getSharedInstance()) + .build(); + } +} diff --git a/application/src/main/java/run/halo/app/notification/Subscriber.java b/application/src/main/java/run/halo/app/notification/Subscriber.java new file mode 100644 index 00000000000..d388c06664a --- /dev/null +++ b/application/src/main/java/run/halo/app/notification/Subscriber.java @@ -0,0 +1,28 @@ +package run.halo.app.notification; + +import java.util.Optional; +import org.springframework.util.Assert; +import run.halo.app.infra.AnonymousUserConst; + +public record Subscriber(UserIdentity identity, String subscriptionName) { + public Subscriber { + Assert.notNull(identity, "The subscriber must not be null"); + Assert.hasText(subscriptionName, "The subscription name must not be blank"); + } + + public String name() { + return identity.name(); + } + + public String username() { + return identity.isAnonymous() ? AnonymousUserConst.PRINCIPAL : identity.name(); + } + + public boolean isAnonymous() { + return identity.isAnonymous(); + } + + public Optional getEmail() { + return identity.getEmail(); + } +} diff --git a/application/src/main/resources/extensions/notification.yaml b/application/src/main/resources/extensions/notification.yaml index 2e6e9dac9f4..2698bf67c33 100644 --- a/application/src/main/resources/extensions/notification.yaml +++ b/application/src/main/resources/extensions/notification.yaml @@ -79,6 +79,9 @@ spec: - name: postName type: string description: "The name of the post." + - name: postOwner + type: string + description: "The user name of the post owner." - name: postTitle type: string - name: postUrl @@ -107,6 +110,9 @@ spec: - name: pageName type: string description: "The name of the single page." + - name: pageOwner + type: string + description: "The user name of the page owner." - name: pageTitle type: string - name: pageUrl @@ -144,6 +150,12 @@ spec: type: boolean - name: commentContent type: string + - name: repliedOwner + type: string + description: "The owner of the comment or reply that has been replied to." + - name: replyOwner + type: string + description: "The user who created the current reply." - name: replier type: string description: "The display name of the replier." diff --git a/application/src/test/java/run/halo/app/content/comment/CommentNotificationReasonPublisherTest.java b/application/src/test/java/run/halo/app/content/comment/CommentNotificationReasonPublisherTest.java index 11848c3508a..7f854d506e2 100644 --- a/application/src/test/java/run/halo/app/content/comment/CommentNotificationReasonPublisherTest.java +++ b/application/src/test/java/run/halo/app/content/comment/CommentNotificationReasonPublisherTest.java @@ -454,21 +454,6 @@ static Reply createReply(String name) { } } - @Test - void identityFromTest() { - var owner = new Comment.CommentOwner(); - owner.setKind(User.KIND); - owner.setName("fake-user"); - - assertThat(CommentNotificationReasonPublisher.identityFrom(owner)) - .isEqualTo(UserIdentity.of(owner.getName())); - - owner.setKind(Comment.CommentOwner.KIND_EMAIL); - owner.setName("example@example.com"); - assertThat(CommentNotificationReasonPublisher.identityFrom(owner)) - .isEqualTo(UserIdentity.anonymousWithEmail(owner.getName())); - } - static Comment createComment() { var comment = new Comment(); comment.setMetadata(new Metadata()); diff --git a/application/src/test/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelperTest.java b/application/src/test/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelperTest.java index 63efcb474bc..01730159939 100644 --- a/application/src/test/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelperTest.java +++ b/application/src/test/java/run/halo/app/content/comment/ReplyNotificationSubscriptionHelperTest.java @@ -7,6 +7,7 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static run.halo.app.content.comment.ReplyNotificationSubscriptionHelper.identityFrom; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -24,9 +25,8 @@ import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.Metadata; import run.halo.app.extension.Ref; -import run.halo.app.infra.AnonymousUserConst; import run.halo.app.notification.NotificationCenter; -import run.halo.app.notification.SubscriberEmailResolver; +import run.halo.app.notification.UserIdentity; /** * Tests for {@link ReplyNotificationSubscriptionHelper}. @@ -40,9 +40,6 @@ class ReplyNotificationSubscriptionHelperTest { @Mock NotificationCenter notificationCenter; - @Mock - SubscriberEmailResolver subscriberEmailResolver; - @InjectMocks ReplyNotificationSubscriptionHelper notificationSubscriptionHelper; @@ -51,17 +48,12 @@ void subscribeNewReplyReasonForCommentTest() { var comment = createComment(); var spyNotificationSubscriptionHelper = spy(notificationSubscriptionHelper); - doNothing().when(spyNotificationSubscriptionHelper).subscribeReply(any(), any()); + doNothing().when(spyNotificationSubscriptionHelper).subscribeReply(any(UserIdentity.class)); spyNotificationSubscriptionHelper.subscribeNewReplyReasonForComment(comment); - var reasonSubject = Subscription.ReasonSubject.builder() - .apiVersion(comment.getApiVersion()) - .kind(comment.getKind()) - .name(comment.getMetadata().getName()) - .build(); - verify(spyNotificationSubscriptionHelper).subscribeReply(eq(reasonSubject), - eq(ReplyNotificationSubscriptionHelper.Identity.fromCommentOwner( + verify(spyNotificationSubscriptionHelper).subscribeReply( + eq(ReplyNotificationSubscriptionHelper.identityFrom( comment.getSpec().getOwner())) ); } @@ -80,17 +72,12 @@ void subscribeNewReplyReasonForReplyTest() { var spyNotificationSubscriptionHelper = spy(notificationSubscriptionHelper); - doNothing().when(spyNotificationSubscriptionHelper).subscribeReply(any(), any()); + doNothing().when(spyNotificationSubscriptionHelper).subscribeReply(any(UserIdentity.class)); spyNotificationSubscriptionHelper.subscribeNewReplyReasonForReply(reply); - var reasonSubject = Subscription.ReasonSubject.builder() - .apiVersion(reply.getApiVersion()) - .kind(reply.getKind()) - .name(reply.getMetadata().getName()) - .build(); - verify(spyNotificationSubscriptionHelper).subscribeReply(eq(reasonSubject), - eq(ReplyNotificationSubscriptionHelper.Identity.fromCommentOwner( + verify(spyNotificationSubscriptionHelper).subscribeReply( + eq(ReplyNotificationSubscriptionHelper.identityFrom( reply.getSpec().getOwner())) ); } @@ -98,48 +85,38 @@ void subscribeNewReplyReasonForReplyTest() { @Test void subscribeReplyTest() { var comment = createComment(); - var reasonSubject = Subscription.ReasonSubject.builder() - .apiVersion(comment.getApiVersion()) - .kind(comment.getKind()) - .name(comment.getMetadata().getName()) - .build(); - var identity = ReplyNotificationSubscriptionHelper.Identity.fromCommentOwner( + var identity = ReplyNotificationSubscriptionHelper.identityFrom( comment.getSpec().getOwner()); when(notificationCenter.subscribe(any(), any())).thenReturn(Mono.empty()); var subscriber = new Subscription.Subscriber(); - subscriber.setName(AnonymousUserConst.PRINCIPAL + "#" + identity.name()); - when(subscriberEmailResolver.ofEmail(eq(identity.name()))) - .thenReturn(subscriber); + subscriber.setName(identity.name()); - notificationSubscriptionHelper.subscribeReply(reasonSubject, identity); + notificationSubscriptionHelper.subscribeReply(identity); var interestReason = new Subscription.InterestReason(); interestReason.setReasonType(NotificationReasonConst.SOMEONE_REPLIED_TO_YOU); - interestReason.setSubject(reasonSubject); + interestReason.setExpression("props.repliedOwner == '%s'".formatted(subscriber.getName())); verify(notificationCenter).subscribe(eq(subscriber), eq(interestReason)); - verify(subscriberEmailResolver).ofEmail(eq(identity.name())); } @Nested class IdentityTest { @Test - void createForCommentOwner() { - var commentOwner = new Comment.CommentOwner(); - commentOwner.setKind(Comment.CommentOwner.KIND_EMAIL); - commentOwner.setName("example@example.com"); - - var sub = ReplyNotificationSubscriptionHelper.Identity.fromCommentOwner(commentOwner); - assertThat(sub.isEmail()).isTrue(); - assertThat(sub.name()).isEqualTo(commentOwner.getName()); - - commentOwner.setKind(User.KIND); - commentOwner.setName("fake-user"); - sub = ReplyNotificationSubscriptionHelper.Identity.fromCommentOwner(commentOwner); - assertThat(sub.isEmail()).isFalse(); - assertThat(sub.name()).isEqualTo(commentOwner.getName()); + void identityFromTest() { + var owner = new Comment.CommentOwner(); + owner.setKind(User.KIND); + owner.setName("fake-user"); + + assertThat(identityFrom(owner)) + .isEqualTo(UserIdentity.of(owner.getName())); + + owner.setKind(Comment.CommentOwner.KIND_EMAIL); + owner.setName("example@example.com"); + assertThat(identityFrom(owner)) + .isEqualTo(UserIdentity.anonymousWithEmail(owner.getName())); } } diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java index be159714027..005ca8b68d9 100644 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java +++ b/application/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java @@ -149,7 +149,7 @@ void reconcileLastModifyTimeWhenPostIsPublished() { when(client.fetch(eq(Post.class), eq(name))) .thenReturn(Optional.of(post)); when(postService.getContent(eq(post.getSpec().getReleaseSnapshot()), - eq(post.getSpec().getBaseSnapshot()))) + eq(post.getSpec().getBaseSnapshot()))) .thenReturn(Mono.just(ContentWrapper.builder() .snapshotName(post.getSpec().getHeadSnapshot()) .raw("hello world") @@ -215,11 +215,7 @@ void subscribeNewCommentNotificationTest() { assertArg(argReason -> { var interestReason = new Subscription.InterestReason(); interestReason.setReasonType(NotificationReasonConst.NEW_COMMENT_ON_POST); - interestReason.setSubject(Subscription.ReasonSubject.builder() - .apiVersion(post.getApiVersion()) - .kind(post.getKind()) - .name(post.getMetadata().getName()) - .build()); + interestReason.setExpression("props.postOwner == 'null'"); assertThat(argReason).isEqualTo(interestReason); })); } diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java index 9ee8f913975..e5dc4d4c9f7 100644 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java +++ b/application/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java @@ -232,11 +232,7 @@ void subscribeNewCommentNotificationTest() { assertArg(argReason -> { var interestReason = new Subscription.InterestReason(); interestReason.setReasonType(NotificationReasonConst.NEW_COMMENT_ON_PAGE); - interestReason.setSubject(Subscription.ReasonSubject.builder() - .apiVersion(page.getApiVersion()) - .kind(page.getKind()) - .name(page.getMetadata().getName()) - .build()); + interestReason.setExpression("props.pageOwner == 'null'"); assertThat(argReason).isEqualTo(interestReason); })); } diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/UserReconcilerTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/UserReconcilerTest.java index 6f1aac113a9..b83e1d3b0ff 100644 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/UserReconcilerTest.java +++ b/application/src/test/java/run/halo/app/core/extension/reconciler/UserReconcilerTest.java @@ -22,6 +22,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.User; @@ -31,6 +32,7 @@ import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.infra.ExternalUrlSupplier; +import run.halo.app.notification.NotificationCenter; /** * Tests for {@link UserReconciler}. @@ -46,6 +48,9 @@ class UserReconcilerTest { @Mock private ExtensionClient client; + @Mock + private NotificationCenter notificationCenter; + @Mock private RoleService roleService; @@ -54,6 +59,7 @@ class UserReconcilerTest { @BeforeEach void setUp() { + lenient().when(notificationCenter.unsubscribe(any(), any())).thenReturn(Mono.empty()); lenient().when(roleService.listRoleRefs(any())).thenReturn(Flux.empty()); } diff --git a/application/src/test/java/run/halo/app/notification/DefaultNotificationCenterTest.java b/application/src/test/java/run/halo/app/notification/DefaultNotificationCenterTest.java index 5c97e1b5b4e..edc29e83dfb 100644 --- a/application/src/test/java/run/halo/app/notification/DefaultNotificationCenterTest.java +++ b/application/src/test/java/run/halo/app/notification/DefaultNotificationCenterTest.java @@ -13,7 +13,6 @@ import java.util.List; import java.util.Locale; -import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -29,7 +28,6 @@ import run.halo.app.core.extension.notification.Reason; import run.halo.app.core.extension.notification.ReasonType; import run.halo.app.core.extension.notification.Subscription; -import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.PageRequest; @@ -60,6 +58,9 @@ class DefaultNotificationCenterTest { @Mock private NotificationSender notificationSender; + @Mock + private RecipientResolver recipientResolver; + @InjectMocks private DefaultNotificationCenter notificationCenter; @@ -78,21 +79,17 @@ public void testNotify() { reason.setMetadata(new Metadata()); reason.getMetadata().setName("reason-a"); - var subscriptionReasonSubject = createNewReplyOnCommentSubject(); - var spyNotificationCenter = spy(notificationCenter); - var subscriptions = createSubscriptions(); - doReturn(Flux.fromIterable(subscriptions)) - .when(spyNotificationCenter).listObservers(eq("new-reply-on-comment"), - eq(subscriptionReasonSubject)); + var subscriber = new Subscriber(UserIdentity.anonymousWithEmail("A"), "fake-name"); + when(recipientResolver.resolve(reason)).thenReturn(Flux.just(subscriber)); + doReturn(Mono.empty()).when(spyNotificationCenter) .dispatchNotification(eq(reason), any()); spyNotificationCenter.notify(reason).block(); verify(spyNotificationCenter).dispatchNotification(eq(reason), any()); - verify(spyNotificationCenter).listObservers(eq("new-reply-on-comment"), - eq(subscriptionReasonSubject)); + verify(recipientResolver).resolve(eq(reason)); } List createSubscriptions() { @@ -203,8 +200,7 @@ public void testGetNotifiersBySubscriber() { reason.getMetadata().setName("reason-a"); reason.setSpec(new Reason.Spec()); reason.getSpec().setReasonType("new-reply-on-comment"); - var subscriber = new Subscription.Subscriber(); - subscriber.setName("anonymousUser#A"); + var subscriber = new Subscriber(UserIdentity.anonymousWithEmail("A"), "fake-name"); notificationCenter.getNotifiersBySubscriber(subscriber, reason) .collectList() @@ -215,7 +211,7 @@ public void testGetNotifiersBySubscriber() { }) .verifyComplete(); - verify(userNotificationPreferenceService).getByUser(eq(subscriber.getName())); + verify(userNotificationPreferenceService).getByUser(eq(subscriber.name())); } @Test @@ -234,7 +230,6 @@ public void testDispatchNotification() { .when(spyNotificationCenter).prepareNotificationElement(any(), any(), any()); doReturn(Mono.empty()).when(spyNotificationCenter).sendNotification(any()); - doReturn(Mono.empty()).when(spyNotificationCenter).createNotification(any()); var reason = new Reason(); reason.setMetadata(new Metadata()); @@ -243,11 +238,15 @@ public void testDispatchNotification() { reason.getSpec().setReasonType("new-reply-on-comment"); var subscription = createSubscriptions().get(0); - spyNotificationCenter.dispatchNotification(reason, subscription).block(); + var subscriptionName = subscription.getMetadata().getName(); + var subscriber = + new Subscriber(UserIdentity.of(subscription.getSpec().getSubscriber().getName()), + subscriptionName); + spyNotificationCenter.dispatchNotification(reason, subscriber).block(); verify(client).fetch(eq(NotifierDescriptor.class), eq("email-notifier")); verify(spyNotificationCenter).sendNotification(any()); - verify(spyNotificationCenter).createNotification(any()); + verify(spyNotificationCenter, times(0)).createNotification(any()); } @Test @@ -282,7 +281,7 @@ public void testSendNotification() { var element = mock(DefaultNotificationCenter.NotificationElement.class); var mockDescriptor = mock(NotifierDescriptor.class); when(element.descriptor()).thenReturn(mockDescriptor); - when(element.subscription()).thenReturn(mock(Subscription.class)); + when(element.subscriber()).thenReturn(mock(Subscriber.class)); var notifierDescriptorSpec = mock(NotifierDescriptor.Spec.class); when(mockDescriptor.getSpec()).thenReturn(notifierDescriptorSpec); when(notifierDescriptorSpec.getNotifierExtName()).thenReturn("fake-notifier-ext"); @@ -299,9 +298,12 @@ public void testCreateNotification() { var subscription = createSubscriptions().get(0); var user = mock(User.class); - var subscriberName = subscription.getSpec().getSubscriber().getName(); - when(client.fetch(eq(User.class), eq(subscriberName))).thenReturn(Mono.just(user)); - when(element.subscription()).thenReturn(subscription); + var subscriptionName = subscription.getMetadata().getName(); + var subscriber = + new Subscriber(UserIdentity.of(subscription.getSpec().getSubscriber().getName()), + subscriptionName); + when(client.fetch(eq(User.class), eq(subscriber.name()))).thenReturn(Mono.just(user)); + when(element.subscriber()).thenReturn(subscriber); when(client.create(any(Notification.class))).thenReturn(Mono.empty()); @@ -314,7 +316,7 @@ public void testCreateNotification() { notificationCenter.createNotification(element).block(); - verify(client).fetch(eq(User.class), eq(subscriberName)); + verify(client).fetch(eq(User.class), eq(subscriber.name())); verify(client).create(any(Notification.class)); } @@ -334,8 +336,8 @@ public void testInferenceTemplate() { doReturn(Mono.just(reasonType)) .when(spyNotificationCenter).getReasonType(eq(reasonTypeName)); - doReturn("fake-url") - .when(spyNotificationCenter).getUnsubscribeUrl(any()); + doReturn(Mono.just("fake-unsubscribe-url")) + .when(spyNotificationCenter).getUnsubscribeUrl(anyString()); final var locale = Locale.CHINESE; @@ -356,98 +358,17 @@ public void testInferenceTemplate() { when(notificationTemplateSelector.select(eq(reasonTypeName), any())) .thenReturn(Mono.just(template)); - var subscription = new Subscription(); - subscription.setSpec(new Subscription.Spec()); - var subscriber = new Subscription.Subscriber(); - subscriber.setName("anonymousUser#A"); - subscription.getSpec().setSubscriber(subscriber); + var subscriber = new Subscriber(UserIdentity.anonymousWithEmail("A"), "fake-name"); - spyNotificationCenter.inferenceTemplate(reason, subscription, locale).block(); + spyNotificationCenter.inferenceTemplate(reason, subscriber, locale).block(); verify(spyNotificationCenter).getReasonType(eq(reasonTypeName)); verify(notificationTemplateSelector).select(eq(reasonTypeName), any()); } - - @Test - void listObserverWhenDuplicateSubscribers() { - var sourceSubscriptions = createSubscriptions(); - var subscriptionA = sourceSubscriptions.get(0); - var subscriptionB = JsonUtils.deepCopy(subscriptionA); - - var subscriptionC = JsonUtils.deepCopy(subscriptionA); - subscriptionC.getSpec().getReason().getSubject().setName(null); - - var subscriptions = Flux.just(subscriptionA, subscriptionB, subscriptionC); - - DefaultNotificationCenter.distinctByKey(subscriptions) - .as(StepVerifier::create) - .expectNext(subscriptionA) - .verifyComplete(); - } - - @Nested - class SubscriptionDistinctKeyPredicateTest { - - @Test - void differentSubjectName() { - var subscriptionA = createSubscriptions().get(0); - var subscriptionB = JsonUtils.deepCopy(subscriptionA); - assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate() - .test(subscriptionA, subscriptionB)).isTrue(); - - subscriptionB.getSpec().getReason().getSubject().setName("other"); - assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate() - .test(subscriptionA, subscriptionB)).isFalse(); - - subscriptionB.getSpec().getReason().getSubject().setName(null); - assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate() - .test(subscriptionA, subscriptionB)).isTrue(); - } - - @Test - void differentSubjectApiVersion() { - var subscriptionA = createSubscriptions().get(0); - var subscriptionB = JsonUtils.deepCopy(subscriptionA); - assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate() - .test(subscriptionA, subscriptionB)).isTrue(); - - var subjectA = subscriptionA.getSpec().getReason().getSubject(); - var gvForA = GroupVersion.parseAPIVersion(subjectA.getApiVersion()); - subscriptionB.getSpec().getReason().getSubject() - .setApiVersion(gvForA.group() + "/otherVersion"); - assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate() - .test(subscriptionA, subscriptionB)).isTrue(); - } - - @Test - void differentReasonType() { - var subscriptionA = createSubscriptions().get(0); - var subscriptionB = JsonUtils.deepCopy(subscriptionA); - assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate() - .test(subscriptionA, subscriptionB)).isTrue(); - - subscriptionB.getSpec().getReason().setReasonType("other"); - assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate() - .test(subscriptionA, subscriptionB)).isFalse(); - } - - @Test - void differentSubscriber() { - var subscriptionA = createSubscriptions().get(0); - var subscriptionB = JsonUtils.deepCopy(subscriptionA); - assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate() - .test(subscriptionA, subscriptionB)).isTrue(); - - subscriptionB.getSpec().getSubscriber().setName("other"); - assertThat(DefaultNotificationCenter.subscriptionDistinctKeyPredicate() - .test(subscriptionA, subscriptionB)).isFalse(); - } - } - @Test void getLocaleFromSubscriberTest() { - var subscription = mock(Subscription.class); + var subscription = mock(Subscriber.class); notificationCenter.getLocaleFromSubscriber(subscription) .as(StepVerifier::create) diff --git a/application/src/test/java/run/halo/app/notification/RecipientResolverImplTest.java b/application/src/test/java/run/halo/app/notification/RecipientResolverImplTest.java new file mode 100644 index 00000000000..3e5a1c10ec3 --- /dev/null +++ b/application/src/test/java/run/halo/app/notification/RecipientResolverImplTest.java @@ -0,0 +1,191 @@ +package run.halo.app.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; +import run.halo.app.core.extension.notification.Reason; +import run.halo.app.core.extension.notification.Subscription; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; + +/** + * Tests for {@link RecipientResolverImpl}. + * + * @author guqing + * @since 2.15.0 + */ +@ExtendWith(MockitoExtension.class) +class RecipientResolverImplTest { + + @Mock + private ReactiveExtensionClient client; + + private RecipientResolverImpl recipientResolver; + + @BeforeEach + void setUp() { + recipientResolver = spy(new RecipientResolverImpl(client)); + } + + @Test + void testExpressionMatch() { + var subscriber1 = new Subscription.Subscriber(); + subscriber1.setName("test"); + final var subscription1 = createSubscription(subscriber1); + subscription1.getMetadata().setName("test-subscription"); + subscription1.getSpec().getReason().setSubject(null); + subscription1.getSpec().getReason().setExpression("props.owner == 'test'"); + + var subscriber2 = new Subscription.Subscriber(); + subscriber2.setName("guqing"); + final var subscription2 = createSubscription(subscriber2); + subscription2.getMetadata().setName("guqing-subscription"); + subscription2.getSpec().getReason().setSubject(null); + subscription2.getSpec().getReason().setExpression("props.owner == 'guqing'"); + + var reason = new Reason(); + reason.setSpec(new Reason.Spec()); + reason.getSpec().setReasonType("new-comment-on-post"); + reason.getSpec().setSubject(new Reason.Subject()); + reason.getSpec().getSubject().setApiVersion("content.halo.run/v1alpha1"); + reason.getSpec().getSubject().setKind("Post"); + reason.getSpec().getSubject().setName("fake-post"); + var reasonAttributes = new ReasonAttributes(); + reasonAttributes.put("owner", "guqing"); + reason.getSpec().setAttributes(reasonAttributes); + + doReturn(Flux.just(subscription1, subscription2)) + .when(recipientResolver).listSubscriptions(anyString()); + + recipientResolver.resolve(reason) + .as(StepVerifier::create) + .expectNext(new Subscriber(UserIdentity.of("guqing"), "guqing-subscription")) + .verifyComplete(); + + verify(recipientResolver).listSubscriptions(any()); + verify(recipientResolver).resolve(eq(reason)); + } + + @Test + void testSubjectMatch() { + var subscriber = new Subscription.Subscriber(); + subscriber.setName("test"); + Subscription subscription = createSubscription(subscriber); + + doReturn(Flux.just(subscription)) + .when(recipientResolver).listSubscriptions(any()); + + var reason = new Reason(); + reason.setSpec(new Reason.Spec()); + reason.getSpec().setReasonType("new-comment-on-post"); + reason.getSpec().setSubject(new Reason.Subject()); + reason.getSpec().getSubject().setApiVersion("content.halo.run/v1alpha1"); + reason.getSpec().getSubject().setKind("Post"); + reason.getSpec().getSubject().setName("fake-post"); + + recipientResolver.resolve(reason) + .as(StepVerifier::create) + .expectNext(new Subscriber(UserIdentity.of("test"), "fake-subscription")) + .verifyComplete(); + + verify(recipientResolver).listSubscriptions(any()); + verify(recipientResolver).resolve(eq(reason)); + } + + @Test + void distinct() { + // same subscriber to different subscriptions + var subscriber = new Subscription.Subscriber(); + subscriber.setName("test"); + + final var subscription1 = createSubscription(subscriber); + subscription1.getMetadata().setName("sub-1"); + + final var subscription2 = createSubscription(subscriber); + subscription2.getMetadata().setName("sub-2"); + subscription2.getSpec().getReason().setSubject(null); + subscription2.getSpec().getReason().setExpression("props.owner == 'guqing'"); + + doReturn(Flux.just(subscription1, subscription2)) + .when(recipientResolver).listSubscriptions(any()); + + var reason = new Reason(); + reason.setSpec(new Reason.Spec()); + reason.getSpec().setReasonType("new-comment-on-post"); + reason.getSpec().setSubject(new Reason.Subject()); + reason.getSpec().getSubject().setApiVersion("content.halo.run/v1alpha1"); + reason.getSpec().getSubject().setKind("Post"); + reason.getSpec().getSubject().setName("fake-post"); + var reasonAttributes = new ReasonAttributes(); + reasonAttributes.put("owner", "guqing"); + reason.getSpec().setAttributes(reasonAttributes); + + recipientResolver.resolve(reason) + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + + verify(recipientResolver).listSubscriptions(any()); + verify(recipientResolver).resolve(eq(reason)); + } + + @Test + void subjectMatchTest() { + var subscriber = new Subscription.Subscriber(); + subscriber.setName("test"); + + final var subscription = createSubscription(subscriber); + + // match all name subscription + var subject = new Reason.Subject(); + subject.setApiVersion("content.halo.run/v1alpha1"); + subject.setKind("Post"); + subject.setName("fake-post"); + assertThat(RecipientResolverImpl.subjectMatch(subscription, subject)).isTrue(); + + // different kind + subject = new Reason.Subject(); + subject.setApiVersion("content.halo.run/v1alpha1"); + subject.setKind("SinglePage"); + subject.setName("fake-post"); + assertThat(RecipientResolverImpl.subjectMatch(subscription, subject)).isFalse(); + + // special case + subscription.getSpec().getReason().getSubject().setName("other-post"); + subject = new Reason.Subject(); + subject.setApiVersion("content.halo.run/v1alpha1"); + subject.setKind("Post"); + subject.setName("fake-post"); + assertThat(RecipientResolverImpl.subjectMatch(subscription, subject)).isFalse(); + subject.setName("other-post"); + assertThat(RecipientResolverImpl.subjectMatch(subscription, subject)).isTrue(); + } + + private static Subscription createSubscription(Subscription.Subscriber subscriber) { + Subscription subscription = new Subscription(); + subscription.setMetadata(new Metadata()); + subscription.getMetadata().setName("fake-subscription"); + subscription.setSpec(new Subscription.Spec()); + subscription.getSpec().setSubscriber(subscriber); + + var interestReason = new Subscription.InterestReason(); + interestReason.setReasonType("new-comment-on-post"); + interestReason.setSubject(new Subscription.ReasonSubject()); + interestReason.getSubject().setApiVersion("content.halo.run/v1alpha1"); + interestReason.getSubject().setKind("Post"); + subscription.getSpec().setReason(interestReason); + return subscription; + } +} diff --git a/docs/notification/README.md b/docs/notification/README.md index f672332e99d..1408ac18f21 100644 --- a/docs/notification/README.md +++ b/docs/notification/README.md @@ -19,7 +19,8 @@ 设计一个通知功能,可以根据以下目标,实现订阅和推送通知: - 支持扩展多种通知方式,例如邮件、短信、Slack 等。 -- 支持通知条件并可扩展,例如 Halo 有新文章发布事件如果用户订阅了新文章发布事件但付费订阅插件决定了此文章只有付费用户才可收到通知、按照付费等级不同决定是否发送新文章通知给对应用户等需要通过实现通知条件的扩展点来满足对应需求。 +- 支持通知条件并可扩展,例如 Halo + 有新文章发布事件如果用户订阅了新文章发布事件但付费订阅插件决定了此文章只有付费用户才可收到通知、按照付费等级不同决定是否发送新文章通知给对应用户等需要通过实现通知条件的扩展点来满足对应需求。 - 支持定制化选项,例如是否开启通知、通知时段等。 - 支持通知流程,例如通知的发送、接收、查看、标记等。 - 通知内容支持多语言。 @@ -97,7 +98,8 @@ spec: #### Subscription -`Subscription` 自定义模型,定义了特定事件时与要被通知的订阅者之间的关系, 其中 `subscriber` 表示订阅者用户, `unsubscribeToken` 表示退订时的身份验证 token, `reason` 订阅者感兴趣的事件。 +`Subscription` 自定义模型,定义了特定事件时与要被通知的订阅者之间的关系, 其中 `subscriber` +表示订阅者用户, `unsubscribeToken` 表示退订时的身份验证 token, `reason` 订阅者感兴趣的事件。 用户可以通过 `Subscription` 来订阅自己感兴趣的事件,当事件触发时会收到通知: @@ -116,13 +118,24 @@ spec: apiVersion: content.halo.run/v1alpha1 kind: Post name: 'post-axgu' + # expression: 'props.owner == "guqing"' ``` -订阅退订链接 API 规则:`/apis/api.notification.halo.run/v1alpha1/subscriptions/{name}/unsubscribe?token={unsubscribeToken}`。 +- `spec.reason.subject`:用于根据事件的主体的匹配感兴趣的事件,如果不指定 name 则表示匹配主体与 kind 和 apiVersion + 相同的一类事件。 +- `spec.expression`:根据表达式匹配感兴趣的事件,例如 `props.owner == "guqing"` 表示只有当事件的属性(reason attributes)的 + owner 等于 guqing 时才会触发通知。表达式符合 SpEL + 表达式语法,但结果只能是布尔值。参考:[增强 Subscription 模型以支持表达式匹配](https://github.com/halo-dev/halo/issues/5632) + +> 当 `spec.expression` 和 `spec.reason.subject` 同时存在时,以 `spec.reason.subject` 的结果为准,不建议同时使用。 + +订阅退订链接 API +规则:`/apis/api.notification.halo.run/v1alpha1/subscriptions/{name}/unsubscribe?token={unsubscribeToken}`。 #### 用户通知偏好设置 -通过在用户偏好设置的 ConfigMap 中存储一个 `notification` key 用于保存事件类型与通知方式的关系设置,当用户订阅了如 'new-comment-on-post' 事件时会获取对应的通知方式来给用户发送通知。 +通过在用户偏好设置的 ConfigMap 中存储一个 `notification` key 用于保存事件类型与通知方式的关系设置,当用户订阅了如 ' +new-comment-on-post' 事件时会获取对应的通知方式来给用户发送通知。 ```yaml apiVersion: v1alpha1 @@ -153,7 +166,8 @@ data: #### Notification 站内通知 -当用户订阅到事件后会创建 `Notification`, 它与通知方式(notifier)无关,`recipient` 为用户名,类似站内通知,如用户 `guqing` 订阅了评论事件那么当监听到评论事件时会创建一条记录可以在个人中心的通知列表看到一条通知消息。 +当用户订阅到事件后会创建 `Notification`, 它与通知方式(notifier)无关,`recipient` 为用户名,类似站内通知,如用户 `guqing` +订阅了评论事件那么当监听到评论事件时会创建一条记录可以在个人中心的通知列表看到一条通知消息。 ```yaml apiVersion: notification.halo.run/v1alpha1 @@ -177,6 +191,7 @@ spec: `GET /apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications` 2. 将通知标记为已读:`PUT /apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/mark-as-read` 3. + 批量将通知标记为已读:`PUT /apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/mark-specified-as-read` #### 通知模板 @@ -185,14 +200,18 @@ spec: 它通过定义 `reasonSelector` 来引用事件类别,当事件触发时会根据用户的语言偏好和触发事件的类别来选择一个最佳的通知模板。 选择通知模板的规则为: -1. 根据用户设置的语言,选择从通知模板中定义的 `spec.reasonSelector.language` 的值从更具体到不太具体的顺序(例如,gl_ES 的值将比 gl 的值具有更高的优先级)。 -2. 当通过语言成功匹配到模板时,匹配到的结果可能不止一个,如 `language` 为 `zh_CN` 的模板有三个那么会根据 `NotificationTemplate` 的 `metadata.creationTimestamp` 字段来选择一个最新的模板。 +1. 根据用户设置的语言,选择从通知模板中定义的 `spec.reasonSelector.language` 的值从更具体到不太具体的顺序(例如,gl_ES 的值将比 + gl 的值具有更高的优先级)。 +2. 当通过语言成功匹配到模板时,匹配到的结果可能不止一个,如 `language` 为 `zh_CN` + 的模板有三个那么会根据 `NotificationTemplate` 的 `metadata.creationTimestamp` 字段来选择一个最新的模板。 这样的规则有助于用户可以个性化定制某些事件的模板内容。 -模板语法使用 ThymeleafEngine 渲染,纯文本模板使用 `textual` 模板模式,语法参考: [usingthymeleaf.html#textual-syntax](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#textual-syntax) +模板语法使用 ThymeleafEngine 渲染,纯文本模板使用 `textual` +模板模式,语法参考: [usingthymeleaf.html#textual-syntax](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#textual-syntax) -`HTML` 则使用标准表达式语法在标签属性中取值,语法参考:[standard-expression-syntax](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#standard-expression-syntax) +`HTML` +则使用标准表达式语法在标签属性中取值,语法参考:[standard-expression-syntax](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#standard-expression-syntax) 在通知中心渲染模板时会在 `ReasonAttributes` 中提供额外属性包括: @@ -224,11 +243,12 @@ spec: #### 通知器声明及扩展 -`NotifierDescriptor` 自定义模型用于声明通知器,通过它来描述通知器的名称、描述和关联的 `ExtensionDefinition` 名称,让用户可以在用户界面知道通知器是什么以及它可以做什么, +`NotifierDescriptor` 自定义模型用于声明通知器,通过它来描述通知器的名称、描述和关联的 `ExtensionDefinition` +名称,让用户可以在用户界面知道通知器是什么以及它可以做什么, 还让 NotificationCenter 知道如何加载通知器和准备通知器需要的设置以发送通知。 ```yaml -apiVersion: notification.halo.run/v1alpha1 +apiVersion: notification.halo.run/v1alpha1 kind: NotifierDescriptor metadata: name: email-notifier @@ -261,52 +281,52 @@ spec: ```java public interface ReactiveNotifier extends ExtensionPoint { - /** - * Notify user. - * - * @param context notification context must not be null - */ - Mono notify(NotificationContext context); + /** + * Notify user. + * + * @param context notification context must not be null + */ + Mono notify(NotificationContext context); } @Data public class NotificationContext { - private Message message; + private Message message; - private ObjectNode receiverConfig; + private ObjectNode receiverConfig; - private ObjectNode senderConfig; + private ObjectNode senderConfig; - @Data - static class Message { - private MessagePayload payload; + @Data + static class Message { + private MessagePayload payload; - private Subject subject; + private Subject subject; - private String recipient; + private String recipient; - private Instant timestamp; - } + private Instant timestamp; + } - @Data - public static class Subject { - private String apiVersion; - private String kind; - private String name; - private String title; - private String url; - } + @Data + public static class Subject { + private String apiVersion; + private String kind; + private String name; + private String title; + private String url; + } - @Data - static class MessagePayload { - private String title; + @Data + static class MessagePayload { + private String title; - private String rawBody; - - private String htmlBody; + private String rawBody; - private ReasonAttributes attributes; - } + private String htmlBody; + + private ReasonAttributes attributes; + } } ```