From 0f2c47211c68cdf1c7001d0c41262c026a365c3d Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Thu, 29 Feb 2024 17:13:31 +0100 Subject: [PATCH] feat(federation): Implement notifications for mentions, reply and full Signed-off-by: Joas Schilling --- lib/Chat/MessageParser.php | 26 +++- lib/Controller/ChatController.php | 2 +- lib/Federation/BackendNotifier.php | 1 + .../CloudFederationProviderTalk.php | 39 ++++- .../TalkV1/Notifier/MessageSentListener.php | 14 +- lib/Model/Message.php | 13 +- ...acheMessages.php => ProxyCacheMessage.php} | 31 +++- ...Mapper.php => ProxyCacheMessageMapper.php} | 26 +++- lib/Notification/FederationChatNotifier.php | 140 ++++++++++++++++++ lib/Notification/Notifier.php | 64 ++++---- lib/Service/RoomFormatter.php | 6 +- .../features/bootstrap/FeatureContext.php | 9 +- .../features/federation/chat.feature | 29 ++++ tests/php/Federation/FederationTest.php | 14 +- tests/php/Notification/NotifierTest.php | 4 + 15 files changed, 358 insertions(+), 60 deletions(-) rename lib/Model/{ProxyCacheMessages.php => ProxyCacheMessage.php} (76%) rename lib/Model/{ProxyCacheMessagesMapper.php => ProxyCacheMessageMapper.php} (72%) create mode 100644 lib/Notification/FederationChatNotifier.php diff --git a/lib/Chat/MessageParser.php b/lib/Chat/MessageParser.php index 77e42316a9ba..de88ab506bc1 100644 --- a/lib/Chat/MessageParser.php +++ b/lib/Chat/MessageParser.php @@ -29,6 +29,7 @@ use OCA\Talk\MatterbridgeManager; use OCA\Talk\Model\Attendee; use OCA\Talk\Model\Message; +use OCA\Talk\Model\ProxyCacheMessage; use OCA\Talk\Participant; use OCA\Talk\Room; use OCA\Talk\Service\BotService; @@ -51,10 +52,10 @@ class MessageParser { protected array $botNames = []; public function __construct( - protected IEventDispatcher $dispatcher, - protected IUserManager $userManager, + protected IEventDispatcher $dispatcher, + protected IUserManager $userManager, protected ParticipantService $participantService, - protected BotService $botService, + protected BotService $botService, ) { } @@ -62,6 +63,25 @@ public function createMessage(Room $room, ?Participant $participant, IComment $c return new Message($room, $participant, $comment, $l); } + public function createMessageFromProxyCache(Room $room, ?Participant $participant, ProxyCacheMessage $proxy, IL10N $l): Message { + $message = new Message($room, $participant, null, $l, $proxy); + + $message->setActor( + $proxy->getActorType(), + $proxy->getActorId(), + $proxy->getActorDisplayName(), + ); + + $message->setMessageType($proxy->getMessageType()); + + $message->setMessage( + $proxy->getMessage(), + $proxy->getParsedMessageParameters() + ); + + return $message; + } + public function parseMessage(Message $message): void { $message->setMessage($message->getComment()->getMessage(), []); diff --git a/lib/Controller/ChatController.php b/lib/Controller/ChatController.php index 5b6a98049025..bad6ee7ea7eb 100644 --- a/lib/Controller/ChatController.php +++ b/lib/Controller/ChatController.php @@ -530,7 +530,7 @@ protected function prepareCommentsAsDataResponse(array $comments, int $lastCommo $message = $this->messageParser->createMessage($this->room, $this->participant, $comment, $this->l); $this->messageParser->parseMessage($message); - $expireDate = $message->getComment()->getExpireDate(); + $expireDate = $message->getExpirationDateTime(); if ($expireDate instanceof \DateTime && $expireDate < $now) { $commentIdToIndex[$id] = null; continue; diff --git a/lib/Federation/BackendNotifier.php b/lib/Federation/BackendNotifier.php index 1b13b260ee7e..ccb422b23bde 100644 --- a/lib/Federation/BackendNotifier.php +++ b/lib/Federation/BackendNotifier.php @@ -313,6 +313,7 @@ public function sendRoomModifiedUpdate( * Send information to remote participants that a message was posted * Sent from Host server to Remote participant server * + * @param array{remoteMessageId: int, actorType: string, actorId: string, actorDisplayName: string, messageType: string, systemMessage: string, expirationDatetime: string, message: string, messageParameter: string, creationDatetime: string, metaData: string} $messageData * @param array{unreadMessages: int, unreadMention: bool, unreadMentionDirect: bool} $unreadInfo */ public function sendMessageUpdate( diff --git a/lib/Federation/CloudFederationProviderTalk.php b/lib/Federation/CloudFederationProviderTalk.php index adf439b65c22..abe0dccd6323 100644 --- a/lib/Federation/CloudFederationProviderTalk.php +++ b/lib/Federation/CloudFederationProviderTalk.php @@ -34,13 +34,15 @@ use OCA\Talk\Events\AttendeesAddedEvent; use OCA\Talk\Exceptions\ParticipantNotFoundException; use OCA\Talk\Exceptions\RoomNotFoundException; +use OCA\Talk\Federation\Proxy\TalkV1\UserConverter; use OCA\Talk\Manager; use OCA\Talk\Model\Attendee; use OCA\Talk\Model\AttendeeMapper; use OCA\Talk\Model\Invitation; use OCA\Talk\Model\InvitationMapper; -use OCA\Talk\Model\ProxyCacheMessages; -use OCA\Talk\Model\ProxyCacheMessagesMapper; +use OCA\Talk\Model\ProxyCacheMessage; +use OCA\Talk\Model\ProxyCacheMessageMapper; +use OCA\Talk\Notification\FederationChatNotifier; use OCA\Talk\Participant; use OCA\Talk\Room; use OCA\Talk\Service\ParticipantService; @@ -87,7 +89,9 @@ public function __construct( private ISession $session, private IEventDispatcher $dispatcher, private LoggerInterface $logger, - private ProxyCacheMessagesMapper $proxyCacheMessagesMapper, + private ProxyCacheMessageMapper $proxyCacheMessageMapper, + private FederationChatNotifier $federationChatNotifier, + private UserConverter $userConverter, ICacheFactory $cacheFactory, ) { $this->proxyCacheMessages = $cacheFactory->isAvailable() ? $cacheFactory->createDistributed('talk/pcm/') : null; @@ -316,7 +320,7 @@ private function roomModified(int $remoteAttendeeId, array $notification): array /** * @param int $remoteAttendeeId - * @param array{remoteServerUrl: string, sharedSecret: string, remoteToken: string, messageData: array{remoteMessageId: int, actorType: string, actorId: string, actorDisplayName: string, messageType: string, systemMessage: string, expirationDatetime: string, message: string, messageParameter: string}, unreadInfo: array{unreadMessages: int, unreadMention: bool, unreadMentionDirect: bool}} $notification + * @param array{remoteServerUrl: string, sharedSecret: string, remoteToken: string, messageData: array{remoteMessageId: int, actorType: string, actorId: string, actorDisplayName: string, messageType: string, systemMessage: string, expirationDatetime: string, message: string, messageParameter: string, creationDatetime: string, metaData: string}, unreadInfo: array{unreadMessages: int, unreadMention: bool, unreadMentionDirect: bool}} $notification * @return array * @throws ActionNotSupportedException * @throws AuthenticationFailedException @@ -335,7 +339,7 @@ private function messagePosted(int $remoteAttendeeId, array $notification): arra throw new ShareNotFound(); } - $message = new ProxyCacheMessages(); + $message = new ProxyCacheMessage(); $message->setLocalToken($room->getToken()); $message->setRemoteServerUrl($notification['remoteServerUrl']); $message->setRemoteToken($notification['remoteToken']); @@ -346,12 +350,25 @@ private function messagePosted(int $remoteAttendeeId, array $notification): arra $message->setMessageType($notification['messageData']['messageType']); $message->setSystemMessage($notification['messageData']['systemMessage']); if ($notification['messageData']['expirationDatetime']) { - $message->setExpirationDatetime(new \DateTimeImmutable($notification['messageData']['expirationDatetime'])); + $message->setExpirationDatetime(new \DateTime($notification['messageData']['expirationDatetime'])); } + + // We transform the parameters when storing in the PCM, so we only have + // to do it once for each message. + $convertedParameters = $this->userConverter->convertMessageParameters($room, [ + 'message' => $notification['messageData']['message'], + 'messageParameters' => json_decode($notification['messageData']['messageParameter'], true, flags: JSON_THROW_ON_ERROR), + ]); + $notification['messageData']['message'] = $convertedParameters['message']; + $notification['messageData']['messageParameter'] = json_encode($convertedParameters['messageParameters'], JSON_THROW_ON_ERROR); + $message->setMessage($notification['messageData']['message']); $message->setMessageParameters($notification['messageData']['messageParameter']); + $message->setCreationDatetime(new \DateTime($notification['messageData']['creationDatetime'])); + $message->setMetaData($notification['messageData']['metaData']); + try { - $this->proxyCacheMessagesMapper->insert($message); + $this->proxyCacheMessageMapper->insert($message); $lastMessageId = $room->getLastMessageId(); if ($notification['messageData']['remoteMessageId'] > $lastMessageId) { @@ -374,6 +391,12 @@ private function messagePosted(int $remoteAttendeeId, array $notification): arra $this->logger->error('Error saving proxy cache message failed: ' . $e->getMessage(), ['exception' => $e]); throw $e; } + + $message = $this->proxyCacheMessageMapper->findByRemote( + $notification['remoteServerUrl'], + $notification['remoteToken'], + $notification['messageData']['remoteMessageId'], + ); } try { @@ -390,6 +413,8 @@ private function messagePosted(int $remoteAttendeeId, array $notification): arra $notification['unreadInfo']['unreadMentionDirect'], ); + $this->federationChatNotifier->handleChatMessage($room, $participant, $message, $notification); + return []; } diff --git a/lib/Federation/Proxy/TalkV1/Notifier/MessageSentListener.php b/lib/Federation/Proxy/TalkV1/Notifier/MessageSentListener.php index e6920cc06540..fb795b80425e 100644 --- a/lib/Federation/Proxy/TalkV1/Notifier/MessageSentListener.php +++ b/lib/Federation/Proxy/TalkV1/Notifier/MessageSentListener.php @@ -32,7 +32,9 @@ use OCA\Talk\Events\SystemMessagesMultipleSentEvent; use OCA\Talk\Federation\BackendNotifier; use OCA\Talk\Model\Attendee; +use OCA\Talk\Model\ProxyCacheMessage; use OCA\Talk\Service\ParticipantService; +use OCP\Comments\IComment; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Federation\ICloudIdManager; @@ -78,6 +80,14 @@ public function handle(Event $event): void { } $expireDate = $event->getComment()->getExpireDate(); + $creationDate = $event->getComment()->getCreationDateTime(); + + $metaData = $event->getComment()->getMetaData() ?? []; + $parent = $event->getParent(); + if ($parent instanceof IComment) { + $metaData[ProxyCacheMessage::METADATA_REPLYTO_TYPE] = $parent->getActorType(); + $metaData[ProxyCacheMessage::METADATA_REPLYTO_ID] = $parent->getActorId(); + } $messageData = [ 'remoteMessageId' => (int) $event->getComment()->getId(), @@ -88,7 +98,9 @@ public function handle(Event $event): void { 'systemMessage' => $chatMessage->getMessageType() === ChatManager::VERB_SYSTEM ? $chatMessage->getMessageRaw() : '', 'expirationDatetime' => $expireDate ? $expireDate->format(\DateTime::ATOM) : '', 'message' => $chatMessage->getMessage(), - 'messageParameter' => json_encode($chatMessage->getMessageParameters()), + 'messageParameter' => json_encode($chatMessage->getMessageParameters(), JSON_THROW_ON_ERROR), + 'creationDatetime' => $creationDate->format(\DateTime::ATOM), + 'metaData' => json_encode($metaData, JSON_THROW_ON_ERROR), ]; $participants = $this->participantService->getParticipantsByActorType($event->getRoom(), Attendee::ACTOR_FEDERATED_USERS); diff --git a/lib/Model/Message.php b/lib/Model/Message.php index 504afa44e747..a0b858f01aaa 100644 --- a/lib/Model/Message.php +++ b/lib/Model/Message.php @@ -80,8 +80,9 @@ class Message { public function __construct( protected Room $room, protected ?Participant $participant, - protected IComment $comment, + protected ?IComment $comment, protected IL10N $l, + protected ?ProxyCacheMessage $proxy = null, ) { } @@ -93,7 +94,7 @@ public function getRoom(): Room { return $this->room; } - public function getComment(): IComment { + public function getComment(): ?IComment { return $this->comment; } @@ -109,6 +110,14 @@ public function getParticipant(): ?Participant { * Parsed message information */ + public function getMessageId(): int { + return $this->comment ? (int) $this->comment->getId() : $this->proxy->getRemoteMessageId(); + } + + public function getExpirationDateTime(): ?\DateTimeInterface { + return $this->comment ? $this->comment->getExpireDate() : $this->proxy->getExpirationDatetime(); + } + public function setVisibility(bool $visible): void { $this->visible = $visible; } diff --git a/lib/Model/ProxyCacheMessages.php b/lib/Model/ProxyCacheMessage.php similarity index 76% rename from lib/Model/ProxyCacheMessages.php rename to lib/Model/ProxyCacheMessage.php index 9803f3e2b700..666dd0f648e0 100644 --- a/lib/Model/ProxyCacheMessages.php +++ b/lib/Model/ProxyCacheMessage.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Joas Schilling + * @copyright Copyright (c) 2024 Joas Schilling * * @author Joas Schilling * @@ -48,16 +48,23 @@ * @method string getMessageType() * @method void setSystemMessage(?string $systemMessage) * @method string|null getSystemMessage() - * @method void setExpirationDatetime(?\DateTimeImmutable $expirationDatetime) - * @method \DateTimeImmutable|null getExpirationDatetime() + * @method void setExpirationDatetime(?\DateTime $expirationDatetime) + * @method \DateTime|null getExpirationDatetime() * @method void setMessage(?string $message) * @method string|null getMessage() * @method void setMessageParameters(?string $messageParameters) * @method string|null getMessageParameters() + * @method void setCreationDatetime(?\DateTime $creationDatetime) + * @method \DateTime|null getCreationDatetime() + * @method void setMetaData(?string $metaData) + * @method string|null getMetaData() * * @psalm-import-type TalkRoomProxyMessage from ResponseDefinitions */ -class ProxyCacheMessages extends Entity implements \JsonSerializable { +class ProxyCacheMessage extends Entity implements \JsonSerializable { + public const METADATA_REPLYTO_TYPE = 'replyToActorType'; + public const METADATA_REPLYTO_ID = 'replyToActorId'; + protected string $localToken = ''; protected string $remoteServerUrl = ''; @@ -68,9 +75,11 @@ class ProxyCacheMessages extends Entity implements \JsonSerializable { protected ?string $actorDisplayName = null; protected ?string $messageType = null; protected ?string $systemMessage = null; - protected ?\DateTimeImmutable $expirationDatetime = null; + protected ?\DateTime $expirationDatetime = null; protected ?string $message = null; protected ?string $messageParameters = null; + protected ?\DateTime $creationDatetime = null; + protected ?string $metaData = null; public function __construct() { $this->addType('localToken', 'string'); @@ -85,6 +94,16 @@ public function __construct() { $this->addType('expirationDatetime', 'datetime'); $this->addType('message', 'string'); $this->addType('messageParameters', 'string'); + $this->addType('creationDatetime', 'datetime'); + $this->addType('metaData', 'string'); + } + + public function getParsedMessageParameters(): array { + return json_decode($this->getMessageParameters() ?? '[]', true); + } + + public function getParsedMetaData(): array { + return json_decode($this->getMetaData() ?? '[]', true); } /** @@ -104,7 +123,7 @@ public function jsonSerialize(): array { 'messageType' => $this->getMessageType(), 'systemMessage' => $this->getSystemMessage() ?? '', 'message' => $this->getMessage() ?? '', - 'messageParameters' => json_decode($this->getMessageParameters() ?? '[]', true), + 'messageParameters' => $this->getParsedMessageParameters(), ]; } } diff --git a/lib/Model/ProxyCacheMessagesMapper.php b/lib/Model/ProxyCacheMessageMapper.php similarity index 72% rename from lib/Model/ProxyCacheMessagesMapper.php rename to lib/Model/ProxyCacheMessageMapper.php index 7d6855fa143e..2f1664949c84 100644 --- a/lib/Model/ProxyCacheMessagesMapper.php +++ b/lib/Model/ProxyCacheMessageMapper.php @@ -32,24 +32,36 @@ use OCP\IDBConnection; /** - * @method ProxyCacheMessages mapRowToEntity(array $row) - * @method ProxyCacheMessages findEntity(IQueryBuilder $query) - * @method ProxyCacheMessages[] findEntities(IQueryBuilder $query) - * @template-extends QBMapper + * @method ProxyCacheMessage mapRowToEntity(array $row) + * @method ProxyCacheMessage findEntity(IQueryBuilder $query) + * @method ProxyCacheMessage[] findEntities(IQueryBuilder $query) + * @template-extends QBMapper */ -class ProxyCacheMessagesMapper extends QBMapper { +class ProxyCacheMessageMapper extends QBMapper { use TTransactional; public function __construct( IDBConnection $db, ) { - parent::__construct($db, 'talk_proxy_messages', ProxyCacheMessages::class); + parent::__construct($db, 'talk_proxy_messages', ProxyCacheMessage::class); } /** * @throws DoesNotExistException */ - public function findByRemote(string $remoteServerUrl, string $remoteToken, int $remoteMessageId): ProxyCacheMessages { + public function findById(int $proxyId): ProxyCacheMessage { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from($this->getTableName()) + ->where($query->expr()->eq('id', $query->createNamedParameter($proxyId, IQueryBuilder::PARAM_INT))); + + return $this->findEntity($query); + } + + /** + * @throws DoesNotExistException + */ + public function findByRemote(string $remoteServerUrl, string $remoteToken, int $remoteMessageId): ProxyCacheMessage { $query = $this->db->getQueryBuilder(); $query->select('*') ->from($this->getTableName()) diff --git a/lib/Notification/FederationChatNotifier.php b/lib/Notification/FederationChatNotifier.php new file mode 100644 index 000000000000..8a630c541b0a --- /dev/null +++ b/lib/Notification/FederationChatNotifier.php @@ -0,0 +1,140 @@ + + * + * @author Joas Schilling + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Talk\Notification; + +use OCA\Talk\Federation\Proxy\TalkV1\UserConverter; +use OCA\Talk\Model\Attendee; +use OCA\Talk\Model\Message; +use OCA\Talk\Model\ProxyCacheMessage; +use OCA\Talk\Participant; +use OCA\Talk\Room; +use OCP\AppFramework\Services\IAppConfig; +use OCP\Notification\IManager; +use OCP\Notification\INotification; + +class FederationChatNotifier { + public function __construct( + protected IAppConfig $appConfig, + protected IManager $notificationManager, + protected UserConverter $userConverter, + ) { + } + + /** + * @param array{remoteServerUrl: string, sharedSecret: string, remoteToken: string, messageData: array{remoteMessageId: int, actorType: string, actorId: string, actorDisplayName: string, messageType: string, systemMessage: string, expirationDatetime: string, message: string, messageParameter: string, creationDatetime: string, metaData: string}, unreadInfo: array{unreadMessages: int, unreadMention: bool, unreadMentionDirect: bool}} $inboundNotification + */ + public function handleChatMessage(Room $room, Participant $participant, ProxyCacheMessage $message, array $inboundNotification): void { + $metaData = json_decode($inboundNotification['messageData']['metaData'] ?? '', true, flags: JSON_THROW_ON_ERROR); + + if (isset($metaData[Message::METADATA_SILENT])) { + // Silent message, skip notification handling + return; + } + + // Also notify default participants in one-to-one chats or when the admin default is "always" + $defaultLevel = $this->appConfig->getAppValueInt('default_group_notification', Participant::NOTIFY_MENTION); + if ($participant->getAttendee()->getNotificationLevel() === Participant::NOTIFY_MENTION + || ($defaultLevel !== Participant::NOTIFY_NEVER && $participant->getAttendee()->getNotificationLevel() === Participant::NOTIFY_DEFAULT)) { + if ($this->isRepliedTo($room, $participant, $metaData)) { + $notification = $this->createNotification($room, $message, 'reply'); + $notification->setUser($participant->getAttendee()->getActorId()); + $this->notificationManager->notify($notification); + } elseif ($this->isMentioned($participant, $message)) { + $notification = $this->createNotification($room, $message, 'mention'); + $notification->setUser($participant->getAttendee()->getActorId()); + $this->notificationManager->notify($notification); + } elseif ($this->isMentionedAll($room, $message)) { + $notification = $this->createNotification($room, $message, 'mention_all'); + $notification->setUser($participant->getAttendee()->getActorId()); + $this->notificationManager->notify($notification); + } + } elseif ($participant->getAttendee()->getNotificationLevel() === Participant::NOTIFY_ALWAYS + || ($defaultLevel === Participant::NOTIFY_ALWAYS && $participant->getAttendee()->getNotificationLevel() === Participant::NOTIFY_DEFAULT)) { + $notification = $this->createNotification($room, $message, 'chat'); + $notification->setUser($participant->getAttendee()->getActorId()); + $this->notificationManager->notify($notification); + } + } + + protected function isRepliedTo(Room $room, Participant $participant, array $metaData): bool { + if ($metaData[ProxyCacheMessage::METADATA_REPLYTO_TYPE] !== Attendee::ACTOR_FEDERATED_USERS) { + return false; + } + + $repliedTo = $this->userConverter->convertTypeAndId($room, $metaData[ProxyCacheMessage::METADATA_REPLYTO_TYPE], $metaData[ProxyCacheMessage::METADATA_REPLYTO_ID]); + return $repliedTo['type'] === $participant->getAttendee()->getActorType() + && $repliedTo['id'] === $participant->getAttendee()->getActorId(); + } + + protected function isMentioned(Participant $participant, ProxyCacheMessage $message): bool { + if ($participant->getAttendee()->getActorType() !== Attendee::ACTOR_USERS) { + return false; + } + + foreach ($message->getParsedMessageParameters() as $parameter) { + if ($parameter['type'] === 'user' // RichObjectDefinition, not Attendee::ACTOR_USERS + && $parameter['id'] === $participant->getAttendee()->getActorId() + && empty($parameter['server'])) { + return true; + } + } + + return false; + } + + protected function isMentionedAll(Room $room, ProxyCacheMessage $message): bool { + foreach ($message->getParsedMessageParameters() as $parameter) { + if ($parameter['type'] === 'call' // RichObjectDefinition + && $parameter['id'] === $room->getRemoteToken()) { + return true; + } + } + + return false; + } + + /** + * Creates a notification for the given proxy message and mentioned users + */ + protected function createNotification(Room $chat, ProxyCacheMessage $message, string $subject, array $subjectData = []): INotification { + $subjectData['userType'] = $message->getActorType(); + $subjectData['userId'] = $message->getActorId(); + + $notification = $this->notificationManager->createNotification(); + $notification + ->setApp('spreed') + ->setObject('chat', $chat->getToken()) + ->setSubject($subject, $subjectData) + ->setMessage($message->getMessageType(), [ + 'proxyId' => $message->getId(), + // FIXME Store more info to allow querying remote? + ]) + ->setDateTime($message->getCreationDatetime()); + + return $notification; + } +} diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index e29f7b7d6ff3..939c7b8da909 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -36,6 +36,8 @@ use OCA\Talk\Manager; use OCA\Talk\Model\Attendee; use OCA\Talk\Model\BotServerMapper; +use OCA\Talk\Model\Message; +use OCA\Talk\Model\ProxyCacheMessageMapper; use OCA\Talk\Participant; use OCA\Talk\Room; use OCA\Talk\Service\AvatarService; @@ -83,6 +85,7 @@ public function __construct( protected AvatarService $avatarService, protected INotificationManager $notificationManager, CommentsManager $commentManager, + protected ProxyCacheMessageMapper $proxyCacheMessageMapper, protected MessageParser $messageParser, protected IRootFolder $rootFolder, protected ITimeFactory $timeFactory, @@ -517,42 +520,51 @@ protected function parseChatMessage(INotification $notification, Room $room, Par ]; $messageParameters = $notification->getMessageParameters(); - if (!isset($messageParameters['commentId'])) { + if (!isset($messageParameters['commentId']) && !isset($messageParameters['proxyId'])) { throw new AlreadyProcessedException(); } - if (!$this->notificationManager->isPreparingPushNotification() - && $notification->getObjectType() === 'chat' - /** - * Notification only contains the message id of the target comment - * not the one of the reaction, so we can't determine if it was read. - * @see Listener::markReactionNotificationsRead() - */ - && $notification->getSubject() !== 'reaction' - && ((int) $messageParameters['commentId']) <= $participant->getAttendee()->getLastReadMessage()) { - // Mark notifications of messages that are read as processed - throw new AlreadyProcessedException(); - } + if (isset($messageParameters['commentId'])) { + if (!$this->notificationManager->isPreparingPushNotification() + && $notification->getObjectType() === 'chat' + /** + * Notification only contains the message id of the target comment + * not the one of the reaction, so we can't determine if it was read. + * @see Listener::markReactionNotificationsRead() + */ + && $notification->getSubject() !== 'reaction' + && ((int) $messageParameters['commentId']) <= $participant->getAttendee()->getLastReadMessage()) { + // Mark notifications of messages that are read as processed + throw new AlreadyProcessedException(); + } - try { - $comment = $this->commentManager->get($messageParameters['commentId']); - } catch (NotFoundException $e) { - throw new AlreadyProcessedException(); - } + try { + $comment = $this->commentManager->get($messageParameters['commentId']); + } catch (NotFoundException $e) { + throw new AlreadyProcessedException(); + } - $message = $this->messageParser->createMessage($room, $participant, $comment, $l); - $this->messageParser->parseMessage($message); + $message = $this->messageParser->createMessage($room, $participant, $comment, $l); + $this->messageParser->parseMessage($message); - if (!$message->getVisibility()) { - throw new AlreadyProcessedException(); + if (!$message->getVisibility()) { + throw new AlreadyProcessedException(); + } + } else { + try { + $proxy = $this->proxyCacheMessageMapper->findById($messageParameters['proxyId']); + $message = $this->messageParser->createMessageFromProxyCache($room, $participant, $proxy, $l); + } catch (DoesNotExistException) { + throw new AlreadyProcessedException(); + } } // Set the link to the specific message - $notification->setLink($this->url->linkToRouteAbsolute('spreed.Page.showCall', ['token' => $room->getToken()]) . '#message_' . $comment->getId()); + $notification->setLink($this->url->linkToRouteAbsolute('spreed.Page.showCall', ['token' => $room->getToken()]) . '#message_' . $message->getMessageId()); $now = $this->timeFactory->getDateTime(); - $expireDate = $message->getComment()->getExpireDate(); - if ($expireDate instanceof \DateTime && $expireDate < $now) { + $expireDate = $message->getExpirationDateTime(); + if ($expireDate instanceof \DateTimeInterface && $expireDate < $now) { throw new AlreadyProcessedException(); } @@ -576,7 +588,7 @@ protected function parseChatMessage(INotification $notification, Room $room, Par $notification->setRichMessage($message->getMessage(), $message->getMessageParameters()); // Forward the message ID as well to the clients, so they can quote the message on replies - $notification->setObject($notification->getObjectType(), $notification->getObjectId() . '/' . $comment->getId()); + $notification->setObject($notification->getObjectType(), $notification->getObjectId() . '/' . $message->getMessageId()); } $richSubjectParameters = [ diff --git a/lib/Service/RoomFormatter.php b/lib/Service/RoomFormatter.php index 1cd8d9fb5f3b..9f2455433c04 100644 --- a/lib/Service/RoomFormatter.php +++ b/lib/Service/RoomFormatter.php @@ -31,7 +31,7 @@ use OCA\Talk\Federation\Proxy\TalkV1\UserConverter; use OCA\Talk\Model\Attendee; use OCA\Talk\Model\BreakoutRoom; -use OCA\Talk\Model\ProxyCacheMessagesMapper; +use OCA\Talk\Model\ProxyCacheMessageMapper; use OCA\Talk\Model\Session; use OCA\Talk\Participant; use OCA\Talk\ResponseDefinitions; @@ -64,7 +64,7 @@ public function __construct( protected IAppManager $appManager, protected IManager $userStatusManager, protected IUserManager $userManager, - protected ProxyCacheMessagesMapper $proxyCacheMessagesMapper, + protected ProxyCacheMessageMapper $proxyCacheMessageMapper, protected UserConverter $userConverter, protected IL10N $l10n, protected ?string $userId, @@ -390,7 +390,7 @@ public function formatRoomV4( ); } elseif ($room->getRemoteServer() !== '') { try { - $cachedMessage = $this->proxyCacheMessagesMapper->findByRemote( + $cachedMessage = $this->proxyCacheMessageMapper->findByRemote( $room->getRemoteServer(), $room->getRemoteToken(), $room->getLastMessageId(), diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index a462025eccbf..281ac14db347 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -1946,6 +1946,8 @@ public function userSeesPeersInCall(string $user, int $numPeers, string $identif public function userSendsMessageToRoom(string $user, string $sendingMode, string $message, string $identifier, string $statusCode, string $apiVersion = 'v1') { $message = substr($message, 1, -1); $message = str_replace('\n', "\n", $message); + $message = str_replace('{$BASE_URL}', $this->baseUrl, $message); + $message = str_replace('{$REMOTE_URL}', $this->baseRemoteUrl, $message); if ($message === '413 Payload Too Large') { $message .= "\n" . str_repeat('1', 32000); @@ -3285,7 +3287,12 @@ private function assertNotifications($notifications, TableNode $formData) { if (isset($expectedNotification['object_id'])) { if (strpos($notification['object_id'], '/') !== false) { [$roomToken, $message] = explode('/', $notification['object_id']); - $data['object_id'] = self::$tokenToIdentifier[$roomToken] . '/' . self::$messageIdToText[$message] ?? 'UNKNOWN_MESSAGE'; + $messageText = self::$messageIdToText[$message] ?? 'UNKNOWN_MESSAGE'; + + $messageText = str_replace($this->baseUrl, '{$BASE_URL}', $messageText); + $messageText = str_replace($this->baseRemoteUrl, '{$REMOTE_URL}', $messageText); + + $data['object_id'] = self::$tokenToIdentifier[$roomToken] . '/' . $messageText; } elseif (strpos($expectedNotification['object_id'], 'INVITE_ID') !== false) { $data['object_id'] = 'INVITE_ID(' . self::$inviteIdToRemote[$notification['object_id']] . ')'; } else { diff --git a/tests/integration/features/federation/chat.feature b/tests/integration/features/federation/chat.feature index a797d15fc24a..77cdd9e0eb86 100644 --- a/tests/integration/features/federation/chat.feature +++ b/tests/integration/features/federation/chat.feature @@ -150,3 +150,32 @@ Feature: federation/chat | id | type | | room | 2 | And user "participant2" sends message "413 Payload Too Large" to room "LOCAL::room" with 413 + + Scenario: Mentioning a federated user triggers a notification for them + Given the following "spreed" app config is set + | federation_enabled | yes | + Given user "participant1" creates room "room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" adds federated_user "participant2" to room "room" with 200 (v4) + And user "participant2" has the following invitations (v1) + | remoteServerUrl | remoteToken | state | inviterCloudId | inviterDisplayName | + | LOCAL | room | 0 | participant1@http://localhost:8080 | participant1-displayname | + And user "participant2" accepts invite to room "room" of server "LOCAL" with 200 (v1) + | id | name | type | remoteServer | remoteToken | + | room | room | 2 | LOCAL | room | + Then user "participant2" is participant of the following rooms (v4) + | id | type | + | room | 2 | + # Join and leave to clear the invite notification + Given user "participant2" joins room "LOCAL::room" with 200 (v4) + Given user "participant2" leaves room "LOCAL::room" with 200 (v4) + And user "participant2" sends message "Message 1" to room "LOCAL::room" with 201 + When user "participant1" sends reply "Message 1-1" on message "Message 1" to room "room" with 201 + And user "participant1" sends message 'Hi @"federated_user/participant2@{$REMOTE_URL}" bye' to room "room" with 201 + And user "participant1" sends message 'Hi @all bye' to room "room" with 201 + Then user "participant2" has the following notifications + | app | object_type | object_id | subject | + | spreed | chat | room/Hi @all bye | participant1-displayname mentioned everyone in conversation room | + | spreed | chat | room/Hi @"federated_user/participant2@{$REMOTE_URL}" bye | participant1-displayname mentioned you in conversation room | + | spreed | chat | room/Message 1-1 | participant1-displayname replied to your message in conversation room | diff --git a/tests/php/Federation/FederationTest.php b/tests/php/Federation/FederationTest.php index f53fa64144eb..2d94caca280d 100644 --- a/tests/php/Federation/FederationTest.php +++ b/tests/php/Federation/FederationTest.php @@ -28,12 +28,14 @@ use OCA\Talk\Federation\BackendNotifier; use OCA\Talk\Federation\CloudFederationProviderTalk; use OCA\Talk\Federation\FederationManager; +use OCA\Talk\Federation\Proxy\TalkV1\UserConverter; use OCA\Talk\Manager; use OCA\Talk\Model\Attendee; use OCA\Talk\Model\AttendeeMapper; use OCA\Talk\Model\Invitation; use OCA\Talk\Model\InvitationMapper; -use OCA\Talk\Model\ProxyCacheMessagesMapper; +use OCA\Talk\Model\ProxyCacheMessageMapper; +use OCA\Talk\Notification\FederationChatNotifier; use OCA\Talk\Room; use OCA\Talk\Service\ParticipantService; use OCA\Talk\Service\RoomService; @@ -95,7 +97,9 @@ class FederationTest extends TestCase { /** @var AttendeeMapper|MockObject */ protected $attendeeMapper; - protected ProxyCacheMessagesMapper|MockObject $proxyCacheMessageMapper; + protected ProxyCacheMessageMapper|MockObject $proxyCacheMessageMapper; + protected FederationChatNotifier|MockObject $federationChatNotifier; + protected UserConverter|MockObject $userConverter; protected ICacheFactory|MockObject $cacheFactory; public function setUp(): void { @@ -112,7 +116,7 @@ public function setUp(): void { $this->appManager = $this->createMock(IAppManager::class); $this->logger = $this->createMock(LoggerInterface::class); $this->url = $this->createMock(IURLGenerator::class); - $this->proxyCacheMessageMapper = $this->createMock(ProxyCacheMessagesMapper::class); + $this->proxyCacheMessageMapper = $this->createMock(ProxyCacheMessageMapper::class); $this->cacheFactory = $this->createMock(ICacheFactory::class); $this->backendNotifier = new BackendNotifier( @@ -130,6 +134,8 @@ public function setUp(): void { $this->federationManager = $this->createMock(FederationManager::class); $this->notificationManager = $this->createMock(INotificationManager::class); + $this->federationChatNotifier = $this->createMock(FederationChatNotifier::class); + $this->userConverter = $this->createMock(UserConverter::class); $this->cloudFederationProvider = new CloudFederationProviderTalk( $this->cloudIdManager, @@ -148,6 +154,8 @@ public function setUp(): void { $this->createMock(IEventDispatcher::class), $this->logger, $this->proxyCacheMessageMapper, + $this->federationChatNotifier, + $this->userConverter, $this->cacheFactory, ); } diff --git a/tests/php/Notification/NotifierTest.php b/tests/php/Notification/NotifierTest.php index 7f2b362d4110..db22eff66f14 100644 --- a/tests/php/Notification/NotifierTest.php +++ b/tests/php/Notification/NotifierTest.php @@ -33,6 +33,7 @@ use OCA\Talk\Model\Attendee; use OCA\Talk\Model\BotServerMapper; use OCA\Talk\Model\Message; +use OCA\Talk\Model\ProxyCacheMessageMapper; use OCA\Talk\Notification\Notifier; use OCA\Talk\Participant; use OCA\Talk\Room; @@ -81,6 +82,7 @@ class NotifierTest extends TestCase { protected $notificationManager; /** @var CommentsManager|MockObject */ protected $commentsManager; + protected ProxyCacheMessageMapper|MockObject $proxyCacheMessageMapper; /** @var MessageParser|MockObject */ protected $messageParser; /** @var IRootFolder|MockObject */ @@ -112,6 +114,7 @@ public function setUp(): void { $this->avatarService = $this->createMock(AvatarService::class); $this->notificationManager = $this->createMock(INotificationManager::class); $this->commentsManager = $this->createMock(CommentsManager::class); + $this->proxyCacheMessageMapper = $this->createMock(ProxyCacheMessageMapper::class); $this->messageParser = $this->createMock(MessageParser::class); $this->rootFolder = $this->createMock(IRootFolder::class); $this->timeFactory = $this->createMock(ITimeFactory::class); @@ -133,6 +136,7 @@ public function setUp(): void { $this->avatarService, $this->notificationManager, $this->commentsManager, + $this->proxyCacheMessageMapper, $this->messageParser, $this->rootFolder, $this->timeFactory,