Шаг 25: Уведомление различными способами

5.0 version
Maintained

Уведомление различными способами

Приложение гостевой книги собирает отзывы о конференциях. Мы хотим улучить обратную связь с нашими пользователями.

Пользователи, вероятно, не понимают, что комментарий находится на проверке, поэтому не публикуется мгновенно. По этой причине они могут повторно его отправить, думая, что произошла техническая ошибка. Было бы здорово уведомить их после отправки комментария.

Кроме того, хорошо бы им сообщить, когда комментарий будет опубликован. Мы просим пользователей указать электронную почту, давайте использовать её.

Существует много способов уведомить пользователей. Электронная почта — это первое, что приходит в голову, хотя мы также можем сделать это и на самом сайте. Отправка SMS-сообщений или уведомлений в Slack или Telegram — вы можете выбрать любой из вариантов.

Компонент Symfony Notifier предлагает множество стратегий уведомления:

1
$ symfony composer req notifier

Отправка уведомлений в браузере

Для начала давайте уведомим пользователей непосредственно в браузере, что их комментарии проверяются после отправки:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -14,6 +14,8 @@ use Symfony\Component\HttpFoundation\File\Exception\FileException;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Notifier\Notification\Notification;
+use Symfony\Component\Notifier\NotifierInterface;
 use Symfony\Component\Routing\Annotation\Route;
 use Symfony\Component\Workflow\Registry;
 use Twig\Environment;
@@ -60,7 +62,7 @@ class ConferenceController extends AbstractController
     /**
      * @Route("/conference/{slug}", name="conference")
      */
-    public function show(Request $request, Conference $conference, CommentRepository $commentRepository, string $photoDir)
+    public function show(Request $request, Conference $conference, CommentRepository $commentRepository, NotifierInterface $notifier, string $photoDir)
     {
         $comment = new Comment();
         $form = $this->createForm(CommentFormType::class, $comment);
@@ -90,9 +92,15 @@ class ConferenceController extends AbstractController

             $this->bus->dispatch(new CommentMessage($comment->getId(), $context));

+            $notifier->send(new Notification('Thank you for the feedback; your comment will be posted after moderation.', ['browser']));
+
             return $this->redirectToRoute('conference', ['slug' => $conference->getSlug()]);
         }

+        if ($form->isSubmitted()) {
+            $notifier->send(new Notification('Can you check your submission? There are some problems with it.', ['browser']));
+        }
+
         $offset = max(0, $request->query->getInt('offset', 0));
         $paginator = $commentRepository->getCommentPaginator($conference, $offset);

Уведомитель отправляет уведомление получателям по каналу.

Уведомление состоит из темы, необязательного содержания и важности.

Уведомление отправляется по одному или нескольким каналам в зависимости от важности. Например, вы можете отправлять срочные уведомления по СМС, а обычные — по электронной почте.

У браузерных уведомлений нет получателей.

Для уведомлений в браузере используются мгновенные сообщения с типом notification. Чтобы вывести их, нам нужно обновить шаблон конференции:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -3,6 +3,13 @@
 {% block title %}Conference Guestbook - {{ conference }}{% endblock %}

 {% block body %}
+    {% for message in app.flashes('notification') %}
+        <div class="alert alert-info alert-dismissible fade show">
+            {{ message }}
+            <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
+        </div>
+    {% endfor %}
+
     <h2 class="mb-5">
         {{ conference }} Conference
     </h2>

Теперь пользователи увидят, что их комментарий проверяется:

А в качестве приятного дополнения в верхней части сайта появится красивое уведомление, если возникнет ошибка при заполнении формы:

Совет

Мгновенные сообщения хранятся в HTTP-сессии. Именно поэтому HTTP-запросы, использующие сессию, не кешируются по умолчанию, так как сессия должна быть запущена, чтобы проверить новые сообщения.

Вот почему, чтобы кешировать главную страницу, мы добавили вывод мгновенных сообщений не базовом шаблоне, а в show.html.twig.

Уведомление администраторов по электронной почте

Вместо отправки администратору электронного письма о новом комментарии с помощью MailerInterface, воспользуемся компонентом Notifier в обработчике сообщений:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
--- a/src/MessageHandler/CommentMessageHandler.php
+++ b/src/MessageHandler/CommentMessageHandler.php
@@ -4,14 +4,14 @@ namespace App\MessageHandler;

 use App\ImageOptimizer;
 use App\Message\CommentMessage;
+use App\Notification\CommentReviewNotification;
 use App\Repository\CommentRepository;
 use App\SpamChecker;
 use Doctrine\ORM\EntityManagerInterface;
 use Psr\Log\LoggerInterface;
-use Symfony\Bridge\Twig\Mime\NotificationEmail;
-use Symfony\Component\Mailer\MailerInterface;
 use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
 use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Notifier\NotifierInterface;
 use Symfony\Component\Workflow\WorkflowInterface;

 class CommentMessageHandler implements MessageHandlerInterface
@@ -21,22 +21,20 @@ class CommentMessageHandler implements MessageHandlerInterface
     private $commentRepository;
     private $bus;
     private $workflow;
-    private $mailer;
+    private $notifier;
     private $imageOptimizer;
-    private $adminEmail;
     private $photoDir;
     private $logger;

-    public function __construct(EntityManagerInterface $entityManager, SpamChecker $spamChecker, CommentRepository $commentRepository, MessageBusInterface $bus, WorkflowInterface $commentStateMachine, MailerInterface $mailer, ImageOptimizer $imageOptimizer, string $adminEmail, string $photoDir, LoggerInterface $logger = null)
+    public function __construct(EntityManagerInterface $entityManager, SpamChecker $spamChecker, CommentRepository $commentRepository, MessageBusInterface $bus, WorkflowInterface $commentStateMachine, NotifierInterface $notifier, ImageOptimizer $imageOptimizer, string $photoDir, LoggerInterface $logger = null)
     {
         $this->entityManager = $entityManager;
         $this->spamChecker = $spamChecker;
         $this->commentRepository = $commentRepository;
         $this->bus = $bus;
         $this->workflow = $commentStateMachine;
-        $this->mailer = $mailer;
+        $this->notifier = $notifier;
         $this->imageOptimizer = $imageOptimizer;
-        $this->adminEmail = $adminEmail;
         $this->photoDir = $photoDir;
         $this->logger = $logger;
     }
@@ -62,13 +60,7 @@ class CommentMessageHandler implements MessageHandlerInterface

             $this->bus->dispatch($message);
         } elseif ($this->workflow->can($comment, 'publish') || $this->workflow->can($comment, 'publish_ham')) {
-            $this->mailer->send((new NotificationEmail())
-                ->subject('New comment posted')
-                ->htmlTemplate('emails/comment_notification.html.twig')
-                ->from($this->adminEmail)
-                ->to($this->adminEmail)
-                ->context(['comment' => $comment])
-            );
+            $this->notifier->send(new CommentReviewNotification($comment), ...$this->notifier->getAdminRecipients());
         } elseif ($this->workflow->can($comment, 'optimize')) {
             if ($comment->getPhotoFilename()) {
                 $this->imageOptimizer->resize($this->photoDir.'/'.$comment->getPhotoFilename());

Метод getAdminRecipients() возвращает список администраторов, которых необходимо уведомить; добавьте в него свою электронную почту:

patch_file
1
2
3
4
5
6
7
8
--- a/config/packages/notifier.yaml
+++ b/config/packages/notifier.yaml
@@ -13,4 +13,4 @@ framework:
             medium: ['email']
             low: ['email']
         admin_recipients:
-            - { email: [email protected] }
+            - { email: "%env(string:default:default_admin_email:ADMIN_EMAIL)%" }

Теперь создайте класс CommentReviewNotification:

src/Notification/CommentReviewNotification.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
namespace App\Notification;

use App\Entity\Comment;
use Symfony\Component\Notifier\Message\EmailMessage;
use Symfony\Component\Notifier\Notification\EmailNotificationInterface;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Recipient\Recipient;

class CommentReviewNotification extends Notification implements EmailNotificationInterface
{
    private $comment;

    public function __construct(Comment $comment)
    {
        $this->comment = $comment;

        parent::__construct('New comment posted');
    }

    public function asEmailMessage(Recipient $recipient, string $transport = null): ?EmailMessage
    {
        $message = EmailMessage::fromNotification($this, $recipient, $transport);
        $message->getMessage()
            ->htmlTemplate('emails/comment_notification.html.twig')
            ->context(['comment' => $this->comment])
        ;

        return $message;
    }
}

Необязательный метод asEmailMessage() интерфейса EmailNotificationInterface позволяет изменить сообщение электронной почты.

Одним из преимуществ использования Notifier вместо соответствующего компонента для отправки почты напрямую состоит в том, что он отделяет уведомление от выбранного «канала». То есть, как вы видите, нет явного указания, что уведомление должно быть отправлено по электронной почте.

Вместо этого канал настраивается в файле config/packages/notifier.yaml и выбирается в зависимости от важности уведомления (low по умолчанию):

config/packages/notifier.yaml
1
2
3
4
5
6
7
8
framework:
notifier:
    channel_policy:
        # use chat/slack, chat/telegram, sms/twilio or sms/nexmo
        urgent: ['email']
        high: ['email']
        medium: ['email']
        low: ['email']

Мы рассмотрели каналы browser и email. Давайте посмотрим на другие, более интересные и сложные.

Отправка уведомлений в чаты для администраторов

Давайте будем честны: мы все ожидаем положительных или, по крайней мере, конструктивных отзывов. Если кто-то напишет комментарий со словами «great» или «awesome», мы, скорее всего, одобрим его быстрее.

Такие сообщения хочется получать не только по электронной почте, но и в чатах, например, в Slack или Telegram.

Добавьте поддержку Slack для Symfony Notifier:

1
$ symfony composer req slack-notifier

Для начала сформируйте строку подключения DSN для Slack с токеном доступа и идентификатором канала Slack, куда вы хотите отправлять сообщения: slack://ACCESS_TOKEN@default?channel=CHANNEL.

Поскольку токен доступа относится к конфиденциальной информации, то сохраните DSN-строку Slack в соответствующем хранилище:

1
$ symfony console secrets:set SLACK_DSN

Проделайте то же самое для продакшена:

1
$ APP_ENV=prod symfony console secrets:set SLACK_DSN

Включите поддержку Chatter в Slack:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
--- a/config/packages/notifier.yaml
+++ b/config/packages/notifier.yaml
@@ -1,7 +1,7 @@
 framework:
     notifier:
-        #chatter_transports:
-        #    slack: '%env(SLACK_DSN)%'
+        chatter_transports:
+            slack: '%env(SLACK_DSN)%'
         #    telegram: '%env(TELEGRAM_DSN)%'
         #texter_transports:
         #    twilio: '%env(TWILIO_DSN)%'

Обновите класс уведомления, чтобы отправлять сообщения в нужные каналы, в зависимости от содержания комментария (с этим справится простое регулярное выражение):

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
--- a/src/Notification/CommentReviewNotification.php
+++ b/src/Notification/CommentReviewNotification.php
@@ -27,4 +27,15 @@ class CommentReviewNotification extends Notification implements EmailNotificationInterface
             ->context(['comment' => $this->comment])
         );
     }
+
+    public function getChannels(Recipient $recipient): array
+    {
+        if (preg_match('{\b(great|awesome)\b}i', $this->comment->getText())) {
+            return ['email', 'chat/slack'];
+        }
+
+        $this->importance(Notification::IMPORTANCE_LOW);
+
+        return ['email'];
+    }
 }

Мы также изменили важность «обычных» комментариев, так как они слегка изменяют дизайн письма.

Готово! Отправьте комментарий, содержащий слово «awesome» и в чате Slack вы увидите этот комментарий.

По аналогии с электронным письмом, вы также можете изменить стандартное оформление сообщения в Slack, если реализуете интерфейс ChatNotificationInterface:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
--- a/src/Notification/CommentReviewNotification.php
+++ b/src/Notification/CommentReviewNotification.php
@@ -3,12 +3,17 @@
 namespace App\Notification;

 use App\Entity\Comment;
+use Symfony\Component\Notifier\Bridge\Slack\Block\SlackDividerBlock;
+use Symfony\Component\Notifier\Bridge\Slack\Block\SlackSectionBlock;
+use Symfony\Component\Notifier\Bridge\Slack\SlackOptions;
+use Symfony\Component\Notifier\Message\ChatMessage;
 use Symfony\Component\Notifier\Message\EmailMessage;
+use Symfony\Component\Notifier\Notification\ChatNotificationInterface;
 use Symfony\Component\Notifier\Notification\EmailNotificationInterface;
 use Symfony\Component\Notifier\Notification\Notification;
 use Symfony\Component\Notifier\Recipient\Recipient;

-class CommentReviewNotification extends Notification implements EmailNotificationInterface
+class CommentReviewNotification extends Notification implements EmailNotificationInterface, ChatNotificationInterface
 {
     private $comment;

@@ -30,6 +35,28 @@ class CommentReviewNotification extends Notification implements EmailNotificatio
         return $message;
     }

+    public function asChatMessage(Recipient $recipient, string $transport = null): ?ChatMessage
+    {
+        if ('slack' !== $transport) {
+            return null;
+        }
+
+        $message = ChatMessage::fromNotification($this, $recipient, $transport);
+        $message->subject($this->getSubject());
+        $message->options((new SlackOptions())
+            ->iconEmoji('tada')
+            ->iconUrl('https://guestbook.example.com')
+            ->username('Guestbook')
+            ->block((new SlackSectionBlock())->text($this->getSubject()))
+            ->block(new SlackDividerBlock())
+            ->block((new SlackSectionBlock())
+                ->text(sprintf('%s (%s) says: %s', $this->comment->getAuthor(), $this->comment->getEmail(), $this->comment->getText()))
+            )
+        );
+
+        return $message;
+    }
+
     public function getChannels(Recipient $recipient): array
     {
         if (preg_match('{\b(great|awesome)\b}i', $this->comment->getText())) {

Так-то лучше, но давайте пойдём ещё дальше. Разве не было бы здорово одобрить или отклонить комментарий непосредственно в Slack?

Измените уведомление, чтобы оно принимало URL-адрес проверки комментария и добавьте две кнопки в сообщение Slack:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
--- a/src/Notification/CommentReviewNotification.php
+++ b/src/Notification/CommentReviewNotification.php
@@ -3,6 +3,7 @@
 namespace App\Notification;

 use App\Entity\Comment;
+use Symfony\Component\Notifier\Bridge\Slack\Block\SlackActionsBlock;
 use Symfony\Component\Notifier\Bridge\Slack\Block\SlackDividerBlock;
 use Symfony\Component\Notifier\Bridge\Slack\Block\SlackSectionBlock;
 use Symfony\Component\Notifier\Bridge\Slack\SlackOptions;
@@ -16,10 +17,12 @@ use Symfony\Component\Notifier\Recipient\Recipient;
 class CommentReviewNotification extends Notification implements EmailNotificationInterface, ChatNotificationInterface
 {
     private $comment;
+    private $reviewUrl;

-    public function __construct(Comment $comment)
+    public function __construct(Comment $comment, string $reviewUrl)
     {
         $this->comment = $comment;
+        $this->reviewUrl = $reviewUrl;

         parent::__construct('New comment posted');
     }
@@ -52,6 +55,10 @@ class CommentReviewNotification extends Notification implements EmailNotificatio
             ->block((new SlackSectionBlock())
                 ->text(sprintf('%s (%s) says: %s', $this->comment->getAuthor(), $this->comment->getEmail(), $this->comment->getText()))
             )
+            ->block((new SlackActionsBlock())
+                ->button('Accept', $this->reviewUrl, 'primary')
+                ->button('Reject', $this->reviewUrl.'?reject=1', 'danger')
+            )
         );

     return $message;

Теперь мы будем вносить изменения в обратном порядке. Для начала обновите обработчик сообщения и передайте в него URL-адрес проверки комментария:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
--- a/src/MessageHandler/CommentMessageHandler.php
+++ b/src/MessageHandler/CommentMessageHandler.php
@@ -60,7 +60,8 @@ class CommentMessageHandler implements MessageHandlerInterface

             $this->bus->dispatch($message);
         } elseif ($this->workflow->can($comment, 'publish') || $this->workflow->can($comment, 'publish_ham')) {
-            $this->notifier->send(new CommentReviewNotification($comment), ...$this->notifier->getAdminRecipients());
+            $notification = new CommentReviewNotification($comment, $message->getReviewUrl());
+            $this->notifier->send($notification, ...$this->notifier->getAdminRecipients());
         } elseif ($this->workflow->can($comment, 'optimize')) {
             if ($comment->getPhotoFilename()) {
                 $this->imageOptimizer->resize($this->photoDir.'/'.$comment->getPhotoFilename());

Как видите, проверочный адрес должен быть в самом сообщении, поэтому добавляем его:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
--- a/src/Message/CommentMessage.php
+++ b/src/Message/CommentMessage.php
@@ -5,14 +5,21 @@ namespace App\Message;
 class CommentMessage
 {
     private $id;
+    private $reviewUrl;
     private $context;

-    public function __construct(int $id, array $context = [])
+    public function __construct(int $id, string $reviewUrl, array $context = [])
     {
         $this->id = $id;
+        $this->reviewUrl = $reviewUrl;
         $this->context = $context;
     }

+    public function getReviewUrl(): string
+    {
+        return $this->reviewUrl;
+    }
+
     public function getId(): int
     {
         return $this->id;

И наконец, сгенерируйте адрес проверки комментария в контроллере, а затем передайте его в конструктор сообщения:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -12,6 +12,7 @@ use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\HttpKernel\KernelInterface;
 use Symfony\Component\Messenger\MessageBusInterface;
 use Symfony\Component\Routing\Annotation\Route;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
 use Symfony\Component\Workflow\Registry;
 use Twig\Environment;

@@ -51,7 +52,8 @@ class AdminController extends AbstractController
         $this->entityManager->flush();

         if ($accepted) {
-            $this->bus->dispatch(new CommentMessage($comment->getId()));
+            $reviewUrl = $this->generateUrl('review_comment', ['id' => $comment->getId()], UrlGeneratorInterface::ABSOLUTE_URL);
+            $this->bus->dispatch(new CommentMessage($comment->getId(), $reviewUrl));
         }

         return $this->render('admin/review.html.twig', [
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -17,6 +17,7 @@ use Symfony\Component\Messenger\MessageBusInterface;
 use Symfony\Component\Notifier\Notification\Notification;
 use Symfony\Component\Notifier\NotifierInterface;
 use Symfony\Component\Routing\Annotation\Route;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
 use Twig\Environment;

 class ConferenceController extends AbstractController
@@ -89,7 +90,8 @@ class ConferenceController extends AbstractController
                 'permalink' => $request->getUri(),
             ];

-            $this->bus->dispatch(new CommentMessage($comment->getId(), $context));
+            $reviewUrl = $this->generateUrl('review_comment', ['id' => $comment->getId()], UrlGeneratorInterface::ABSOLUTE_URL);
+            $this->bus->dispatch(new CommentMessage($comment->getId(), $reviewUrl, $context));

             $notifier->send(new Notification('Thank you for the feedback; your comment will be posted after moderation.', ['browser']));

Декомпозиция кода предполагает изменения в большем количестве мест, но зато она облегчает тестирование, анализ и повторное использование.

Попробуйте ещё раз: сообщение должно быть правильным:

../_images/slack-message.png

Включение асинхронного режима для всех каналов

Давайте я объясняю небольшую проблему, которую нам нужно исправить. При каждом добавленном комментарии мы получаем электронное письмо и сообщение в Slack. Если при отправке Slack-сообщения возникает ошибка (неправильный идентификатор канала, неверный токен и т.п.), то произойдёт повторная отправка сообщения три раза, прежде чем оно будет отклонено. Но поскольку сначала отправляется уведомление по почте, то в итоге у нас будут 3 электронных письма и ни одного сообщения в Slack. Одним из способов решения этой проблемы — это отправка Slack-сообщений асинхронно, как это уже сделано для электронной почтой:

patch_file
1
2
3
4
5
6
7
8
--- a/config/packages/messenger.yaml
+++ b/config/packages/messenger.yaml
@@ -20,3 +20,5 @@ framework:
             # Route your messages to the transports
             App\Message\CommentMessage: async
             Symfony\Component\Mailer\Messenger\SendEmailMessage: async
+            Symfony\Component\Notifier\Message\ChatMessage: async
+            Symfony\Component\Notifier\Message\SmsMessage: async

После того, как во все каналы сообщения отправляются асинхронно, то сами сообщения перестают зависеть друг от друга. Вдобавок мы также включили асинхронную пересылку SMS-сообщений на случай, если вам понадобится получать уведомления на свой телефон.

Уведомление пользователей по электронной почте

Последняя задача — уведомить пользователей, когда их комментарий будет одобрен. Как насчёт того, чтобы реализовать это самостоятельно?


  • « Previous Шаг 24: Выполнение заданий cron
  • Next » Шаг 26: Создание API с помощью API Platform

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.