From d80c30beb67983192627a7b37898c99810780f3b Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Mon, 7 Aug 2023 14:39:28 +0200 Subject: [PATCH] feat(bot): Allow reactions by bots Signed-off-by: Joas Schilling --- appinfo/routes/routesBotController.php | 10 ++ lib/Chat/ReactionManager.php | 30 ++-- lib/Controller/BotController.php | 139 ++++++++++++++---- lib/Controller/ReactionController.php | 6 +- .../features/bootstrap/FeatureContext.php | 9 ++ tests/integration/features/chat/bots.feature | 8 + 6 files changed, 161 insertions(+), 41 deletions(-) diff --git a/appinfo/routes/routesBotController.php b/appinfo/routes/routesBotController.php index f61d6aa1f10..90910d2a3d4 100644 --- a/appinfo/routes/routesBotController.php +++ b/appinfo/routes/routesBotController.php @@ -28,6 +28,12 @@ 'token' => '[a-z0-9]{4,30}', ]; +$requirementsWithMessageId = [ + 'apiVersion' => 'v1', + 'token' => '[a-z0-9]{4,30}', + 'messageId' => '[0-9]+', +]; + $requirementsWithBotId = [ 'apiVersion' => 'v1', 'token' => '[a-z0-9]{4,30}', @@ -38,6 +44,10 @@ 'ocs' => [ /** @see \OCA\Talk\Controller\BotController::sendMessage() */ ['name' => 'Bot#sendMessage', 'url' => '/api/{apiVersion}/bot/{token}/message', 'verb' => 'POST', 'requirements' => $requirements], + /** @see \OCA\Talk\Controller\BotController::react() */ + ['name' => 'Bot#react', 'url' => '/api/{apiVersion}/bot/{token}/reaction/{messageId}', 'verb' => 'POST', 'requirements' => $requirementsWithMessageId], + /** @see \OCA\Talk\Controller\BotController::deleteReaction() */ + ['name' => 'Bot#deleteReaction', 'url' => '/api/{apiVersion}/bot/{token}/reaction/{messageId}', 'verb' => 'DELETE', 'requirements' => $requirementsWithMessageId], /** @see \OCA\Talk\Controller\BotController::listBots() */ ['name' => 'Bot#listBots', 'url' => '/api/{apiVersion}/bot/{token}', 'verb' => 'GET', 'requirements' => $requirements], /** @see \OCA\Talk\Controller\BotController::enableBot() */ diff --git a/lib/Chat/ReactionManager.php b/lib/Chat/ReactionManager.php index 175038c3451..fbd97a622f1 100644 --- a/lib/Chat/ReactionManager.php +++ b/lib/Chat/ReactionManager.php @@ -51,7 +51,8 @@ public function __construct( * Add reaction * * @param Room $chat - * @param Participant $participant + * @param string $actorType + * @param string $actorId * @param integer $messageId * @param string $reaction * @return IComment @@ -60,14 +61,14 @@ public function __construct( * @throws ReactionNotSupportedException * @throws ReactionOutOfContextException */ - public function addReactionMessage(Room $chat, Participant $participant, int $messageId, string $reaction): IComment { + public function addReactionMessage(Room $chat, string $actorType, string $actorId, int $messageId, string $reaction): IComment { $parentMessage = $this->getCommentToReact($chat, (string) $messageId); try { // Check if the user already reacted with the same reaction $this->commentsManager->getReactionComment( (int) $parentMessage->getId(), - $participant->getAttendee()->getActorType(), - $participant->getAttendee()->getActorId(), + $actorType, + $actorId, $reaction ); throw new ReactionAlreadyExistsException(); @@ -75,8 +76,8 @@ public function addReactionMessage(Room $chat, Participant $participant, int $me } $comment = $this->commentsManager->create( - $participant->getAttendee()->getActorType(), - $participant->getAttendee()->getActorId(), + $actorType, + $actorId, 'chat', (string) $chat->getId() ); @@ -93,7 +94,8 @@ public function addReactionMessage(Room $chat, Participant $participant, int $me * Delete reaction * * @param Room $chat - * @param Participant $participant + * @param string $actorType + * @param string $actorId * @param integer $messageId * @param string $reaction * @return IComment @@ -101,20 +103,20 @@ public function addReactionMessage(Room $chat, Participant $participant, int $me * @throws ReactionNotSupportedException * @throws ReactionOutOfContextException */ - public function deleteReactionMessage(Room $chat, Participant $participant, int $messageId, string $reaction): IComment { + public function deleteReactionMessage(Room $chat, string $actorType, string $actorId, int $messageId, string $reaction): IComment { // Just to verify that messageId is part of the room and throw error if not. $this->getCommentToReact($chat, (string) $messageId); $comment = $this->commentsManager->getReactionComment( $messageId, - $participant->getAttendee()->getActorType(), - $participant->getAttendee()->getActorId(), + $actorType, + $actorId, $reaction ); $comment->setMessage( json_encode([ - 'deleted_by_type' => $participant->getAttendee()->getActorType(), - 'deleted_by_id' => $participant->getAttendee()->getActorId(), + 'deleted_by_type' => $actorType, + 'deleted_by_id' => $actorId, 'deleted_on' => $this->timeFactory->getDateTime()->getTimestamp(), ]) ); @@ -123,8 +125,8 @@ public function deleteReactionMessage(Room $chat, Participant $participant, int $this->chatManager->addSystemMessage( $chat, - $participant->getAttendee()->getActorType(), - $participant->getAttendee()->getActorId(), + $actorType, + $actorId, json_encode(['message' => 'reaction_revoked', 'parameters' => ['message' => (int) $comment->getId()]]), $this->timeFactory->getDateTime(), false, diff --git a/lib/Controller/BotController.php b/lib/Controller/BotController.php index 6492bd95e8a..3e763dc13db 100644 --- a/lib/Controller/BotController.php +++ b/lib/Controller/BotController.php @@ -27,6 +27,10 @@ namespace OCA\Talk\Controller; use OCA\Talk\Chat\ChatManager; +use OCA\Talk\Chat\ReactionManager; +use OCA\Talk\Exceptions\ReactionAlreadyExistsException; +use OCA\Talk\Exceptions\ReactionNotSupportedException; +use OCA\Talk\Exceptions\ReactionOutOfContextException; use OCA\Talk\Exceptions\UnauthorizedException; use OCA\Talk\Manager; use OCA\Talk\Middleware\Attribute\RequireLoggedInModeratorParticipant; @@ -63,42 +67,31 @@ public function __construct( protected BotServerMapper $botServerMapper, protected BotService $botService, protected Manager $manager, + protected ReactionManager $reactionManager, protected LoggerInterface $logger, ) { parent::__construct($appName, $request); } /** - * Sends a new chat message to the given room. - * - * The author and timestamp are automatically set to the current user/guest - * and time. - * - * @param string $token conversation token - * @param string $message the message to send - * @param string $referenceId for the message to be able to later identify it again - * @param int $replyTo Parent id which this message is a reply to - * @param bool $silent If sent silent the chat message will not create any notifications - * @return DataResponse the status code is "201 Created" if successful, and - * "404 Not found" if the room or session for a guest user was not - * found". + * @param string $token + * @param string $message + * @return Bot + * @throws \InvalidArgumentException When the request could not be linked with a bot */ - #[BruteForceProtection(action: 'bot')] - #[PublicPage] - public function sendMessage(string $token, string $message, string $referenceId = '', int $replyTo = 0, bool $silent = false): DataResponse { + protected function getBotFromHeaders(string $token, string $message): Bot { $random = $this->request->getHeader('X-Nextcloud-Talk-Bot-Random'); if (empty($random) || strlen($random) < 32) { $this->logger->error('Invalid Random received from bot response'); - return new DataResponse([], Http::STATUS_BAD_REQUEST); + throw new \InvalidArgumentException('Invalid Random received from bot response', Http::STATUS_BAD_REQUEST); } $checksum = $this->request->getHeader('X-Nextcloud-Talk-Bot-Signature'); if (empty($checksum)) { $this->logger->error('Invalid Signature received from bot response'); - return new DataResponse([], Http::STATUS_BAD_REQUEST); + throw new \InvalidArgumentException('Invalid Signature received from bot response', Http::STATUS_BAD_REQUEST); } $bots = $this->botService->getBotsForToken($token); - $bot = null; foreach ($bots as $botAttempt) { try { $this->checksumVerificationService->validateRequest( @@ -107,16 +100,40 @@ public function sendMessage(string $token, string $message, string $referenceId $botAttempt->getBotServer()->getSecret(), $message ); - $bot = $botAttempt; - break; + return $botAttempt; } catch (UnauthorizedException) { } } - if (!$bot instanceof Bot) { - $this->logger->debug('No valid Bot entry found'); - $response = new DataResponse([], Http::STATUS_UNAUTHORIZED); - $response->throttle(['action' => 'bot']); + $this->logger->debug('No valid Bot entry found'); + throw new \InvalidArgumentException('No valid Bot entry found', Http::STATUS_UNAUTHORIZED); + } + + /** + * Sends a new chat message to the given room. + * + * The author and timestamp are automatically set to the current user/guest + * and time. + * + * @param string $token conversation token + * @param string $message the message to send + * @param string $referenceId for the message to be able to later identify it again + * @param int $replyTo Parent id which this message is a reply to + * @param bool $silent If sent silent the chat message will not create any notifications + * @return DataResponse the status code is "201 Created" if successful, and + * "404 Not found" if the room or session for a guest user was not + * found". + */ + #[BruteForceProtection(action: 'bot')] + #[PublicPage] + public function sendMessage(string $token, string $message, string $referenceId = '', int $replyTo = 0, bool $silent = false): DataResponse { + try { + $bot = $this->getBotFromHeaders($token, $message); + } catch (\InvalidArgumentException $e) { + $response = new DataResponse([], $e->getCode()); + if ($e->getCode() === Http::STATUS_UNAUTHORIZED) { + $response->throttle(['action' => 'bot']); + } return $response; } @@ -149,6 +166,78 @@ public function sendMessage(string $token, string $message, string $referenceId return new DataResponse([], Http::STATUS_CREATED); } + #[BruteForceProtection(action: 'bot')] + #[PublicPage] + public function react(string $token, int $messageId, string $reaction): DataResponse { + try { + $bot = $this->getBotFromHeaders($token, $reaction); + } catch (\InvalidArgumentException $e) { + $response = new DataResponse([], $e->getCode()); + if ($e->getCode() === Http::STATUS_UNAUTHORIZED) { + $response->throttle(['action' => 'bot']); + } + return $response; + } + + $room = $this->manager->getRoomByToken($token); + + $actorType = Attendee::ACTOR_BOTS; + $actorId = Attendee::ACTOR_BOT_PREFIX . $bot->getBotServer()->getUrlHash(); + + try { + $this->reactionManager->addReactionMessage( + $room, + $actorType, + $actorId, + $messageId, + $reaction + ); + } catch (NotFoundException) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } catch (ReactionAlreadyExistsException) { + return new DataResponse([], Http::STATUS_OK); + } catch (ReactionNotSupportedException | ReactionOutOfContextException | \Exception) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse([], Http::STATUS_CREATED); + } + + #[BruteForceProtection(action: 'bot')] + #[PublicPage] + public function deleteReaction(string $token, int $messageId, string $reaction): DataResponse { + try { + $bot = $this->getBotFromHeaders($token, $reaction); + } catch (\InvalidArgumentException $e) { + $response = new DataResponse([], $e->getCode()); + if ($e->getCode() === Http::STATUS_UNAUTHORIZED) { + $response->throttle(['action' => 'bot']); + } + return $response; + } + + $room = $this->manager->getRoomByToken($token); + + $actorType = Attendee::ACTOR_BOTS; + $actorId = Attendee::ACTOR_BOT_PREFIX . $bot->getBotServer()->getUrlHash(); + + try { + $this->reactionManager->deleteReactionMessage( + $room, + $actorType, + $actorId, + $messageId, + $reaction + ); + } catch (ReactionNotSupportedException | ReactionOutOfContextException | NotFoundException) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } catch (\Exception) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse([], Http::STATUS_OK); + } + #[NoAdminRequired] #[RequireLoggedInModeratorParticipant] public function listBots(): DataResponse { diff --git a/lib/Controller/ReactionController.php b/lib/Controller/ReactionController.php index 2a2472a04be..aecf7f07aa9 100644 --- a/lib/Controller/ReactionController.php +++ b/lib/Controller/ReactionController.php @@ -58,7 +58,8 @@ public function react(int $messageId, string $reaction): DataResponse { try { $this->reactionManager->addReactionMessage( $this->getRoom(), - $this->getParticipant(), + $this->getParticipant()->getAttendee()->getActorType(), + $this->getParticipant()->getAttendee()->getActorId(), $messageId, $reaction ); @@ -83,7 +84,8 @@ public function delete(int $messageId, string $reaction): DataResponse { try { $this->reactionManager->deleteReactionMessage( $this->getRoom(), - $this->getParticipant(), + $this->getParticipant()->getAttendee()->getActorType(), + $this->getParticipant()->getAttendee()->getActorId(), $messageId, $reaction ); diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index a93cc2257c5..ad56c77e779 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -3191,6 +3191,7 @@ public function userReactWithOnMessageToRoomWith(string $user, string $action, s * @Given /^user "([^"]*)" retrieve reactions "([^"]*)" of message "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/ */ public function userRetrieveReactionsOfMessageInRoomWith(string $user, string $reaction, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { + $message = str_replace('\n', "\n", $message); $token = self::$identifierToToken[$identifier]; $messageId = self::$textToMessageId[$message]; $this->setCurrentUser($user); @@ -3208,6 +3209,14 @@ private function assertReactionList(?TableNode $formData): void { foreach ($formData->getHash() as $row) { $reaction = $row['reaction']; unset($row['reaction']); + + if ($row['actorType'] === 'bots') { + $result = preg_match('/BOT\(([^)]+)\)/', $row['actorId'], $matches); + if ($result && isset(self::$botNameToHash[$matches[1]])) { + $row['actorId'] = 'bot-' . self::$botNameToHash[$matches[1]]; + } + } + $expected[$reaction][] = $row; } diff --git a/tests/integration/features/chat/bots.feature b/tests/integration/features/chat/bots.feature index 6a1024c00ae..89ff60b5665 100644 --- a/tests/integration/features/chat/bots.feature +++ b/tests/integration/features/chat/bots.feature @@ -60,6 +60,14 @@ Feature: chat/bots | room | users | participant1 | participant1-displayname | - Task 2\n-Task 3 | [] | | room | users | participant1 | participant1-displayname | * Task 1 | [] | | room | users | participant1 | participant1-displayname | - Before call | [] | + Then user "participant1" retrieve reactions "👍" of message "- Before call" in room "room" with 200 + | actorType | actorId | actorDisplayName | reaction | + Then user "participant1" retrieve reactions "👍" of message "* Task 1" in room "room" with 200 + | actorType | actorId | actorDisplayName | reaction | + | bots | BOT(Call summary) | Call summary (Bot) | 👍 | + Then user "participant1" retrieve reactions "👍" of message "- Task 2\n-Task 3" in room "room" with 200 + | actorType | actorId | actorDisplayName | reaction | + | bots | BOT(Call summary) | Call summary (Bot) | 👍 | # Different states bot # Already enabled