From e8af04d5579ab0c165de7c45fee6c42a6f368d47 Mon Sep 17 00:00:00 2001 From: Sanskar Soni Date: Fri, 28 Jun 2024 15:18:29 +0530 Subject: [PATCH] feat(bots): Add events for enabling and disabling bots Signed-off-by: Sanskar Soni --- docs/bots.md | 94 +++++++++++++++++++++++- docs/events.md | 14 ++++ lib/AppInfo/Application.php | 4 + lib/Command/Bot/Remove.php | 28 ++++++- lib/Command/Bot/Setup.php | 10 ++- lib/Controller/BotController.php | 12 +++ lib/Events/BotDisabledEvent.php | 26 +++++++ lib/Events/BotEnabledEvent.php | 26 +++++++ lib/Listener/BotListener.php | 17 ++++- lib/Service/BotService.php | 121 +++++++++++++++++++++---------- 10 files changed, 306 insertions(+), 46 deletions(-) create mode 100644 lib/Events/BotDisabledEvent.php create mode 100644 lib/Events/BotEnabledEvent.php diff --git a/docs/bots.md b/docs/bots.md index 42613636bba..b5817a4888a 100644 --- a/docs/bots.md +++ b/docs/bots.md @@ -13,7 +13,7 @@ Webhook based bots are available with the Nextcloud 27.1 compatible Nextcloud Ta --- -## Receiving chat messages +## Signing and Verifying Requests Messages are signed using the shared secret that is specified when installing a bot on the server. Create a HMAC with SHA256 over the `RANDOM` header and the request body using the shared secret. @@ -29,6 +29,10 @@ if (!hash_equals($digest, strtolower($_SERVER['HTTP_X_NEXTCLOUD_TALK_SIGNATURE'] } ``` +## Receiving chat messages + +Bot receives all the chat messages following the same signature/verification method. + ### Headers | Header | Content type | Description | @@ -79,6 +83,92 @@ The content format follows the [Activity Streams 2.0 Vocabulary](https://www.w3. | target.id | The token of the conversation in which the message was posted. It can be used to react or reply to the given message. | | target.name | The name of the conversation in which the message was posted. | +## Bot added in a chat + +When the bot is added to a chat, the server sends a request to the bot, informing it of the event. The same signature/verification method is applied. + +### Headers + +| Header | Content type | Description | +|-----------------------------------|---------------------|------------------------------------------------------| +| `HTTP_X_NEXTCLOUD_TALK_SIGNATURE` | `[a-f0-9]{64}` | SHA265 signature of the body | +| `HTTP_X_NEXTCLOUD_TALK_RANDOM` | `[A-Za-z0-9+\]{64}` | Random string used when signing the body | +| `HTTP_X_NEXTCLOUD_TALK_BACKEND` | URI | Base URL of the Nextcloud server sending the message | + +### Content + +The content format follows the [Activity Streams 2.0 Vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/). + +#### Sample request + +```json +{ + "type": "Join", + "actor": { + "type": "Application", + "id": "bots/bot-a78f46c5c203141b247554e180e1aa3553d282c6", + "name": "Bot123" + }, + "target": { + "type": "Collection", + "id": "n3xtc10ud", + "name": "world" + } +} +``` + +#### Explanation + +| Path | Description | +|------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| actor.id | Bot's [actor type](constants.md#actor-types-of-chat-messages) followed by the `/` slash character and a bot's unique sha1 identifier with `bot-` prefix. | +| actor.name | The display name of the bot. | +| target.id | The token of the conversation in which the bot was added. | +| target.name | The name of the conversation in which the bot was added. | + +## Bot removed from a chat + +When the bot is removed from a chat, the server sends a request to the bot, informing it of the event. The same signature/verification method is applied. + +### Headers + +| Header | Content type | Description | +|-----------------------------------|---------------------|------------------------------------------------------| +| `HTTP_X_NEXTCLOUD_TALK_SIGNATURE` | `[a-f0-9]{64}` | SHA265 signature of the body | +| `HTTP_X_NEXTCLOUD_TALK_RANDOM` | `[A-Za-z0-9+\]{64}` | Random string used when signing the body | +| `HTTP_X_NEXTCLOUD_TALK_BACKEND` | URI | Base URL of the Nextcloud server sending the message | + +### Content + +The content format follows the [Activity Streams 2.0 Vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/). + +#### Sample request + +```json +{ + "type": "Leave", + "actor": { + "type": "Application", + "id": "bots/bot-a78f46c5c203141b247554e180e1aa3553d282c6", + "name": "Bot123" + }, + "target": { + "type": "Collection", + "id": "n3xtc10ud", + "name": "world" + } +} +``` + +#### Explanation + +| Path | Description | +|------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| actor.id | Bot's [actor type](constants.md#actor-types-of-chat-messages) followed by the `/` slash character and a bot's unique sha1 identifier with `bot-` prefix. | +| actor.name | The display name of the bot. | +| target.id | The token of the conversation from which the bot was removed. | +| target.name | The name of the conversation from which the bot was removed. | + ## Sending a chat message Bots can also send message. On the sending process the same signature/verification method is applied. @@ -143,7 +233,7 @@ Bots can also react to a message. The same signature/verification method is appl ## Delete a reaction -Bots can also remove their previous reaction from amessage. The same signature/verification method is applied. +Bots can also remove their previous reaction from a message. The same signature/verification method is applied. * Required capability: `bots-v1` * Method: `DELETE` diff --git a/docs/events.md b/docs/events.md index f71b82fce1d..93d902c6e19 100644 --- a/docs/events.md +++ b/docs/events.md @@ -176,6 +176,20 @@ listen to the `OCA\Talk\Events\SystemMessagesMultipleSentEvent` event instead. * After event: *Not available* * Since: 18.0.0 +### Bot enabled + +Sends a request to the bot server, informing it was added in a chat. + +* Event: `OCA\Talk\Events\BotEnabledEvent` +* Since: 20.0.0 + +### Bot disabled + +Sends a request to the bot server, informing it was removed from a chat. + +* Event: `OCA\Talk\Events\BotDisabledEvent` +* Since: 20.0.0 + ## Inbound events to invoke Talk ### Bot install diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 9dc98304712..c0bac6371b6 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -43,6 +43,8 @@ use OCA\Talk\Events\BeforeRoomsFetchEvent; use OCA\Talk\Events\BeforeSessionLeftRoomEvent; use OCA\Talk\Events\BeforeUserJoinedRoomEvent; +use OCA\Talk\Events\BotDisabledEvent; +use OCA\Talk\Events\BotEnabledEvent; use OCA\Talk\Events\BotInstallEvent; use OCA\Talk\Events\BotUninstallEvent; use OCA\Talk\Events\CallEndedForEveryoneEvent; @@ -174,6 +176,8 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(SessionLeftRoomEvent::class, ActivityListener::class, -100); // Bot listeners + $context->registerEventListener(BotDisabledEvent::class, BotListener::class); + $context->registerEventListener(BotEnabledEvent::class, BotListener::class); $context->registerEventListener(BotInstallEvent::class, BotListener::class); $context->registerEventListener(BotUninstallEvent::class, BotListener::class); $context->registerEventListener(ChatMessageSentEvent::class, BotListener::class); diff --git a/lib/Command/Bot/Remove.php b/lib/Command/Bot/Remove.php index e822cf6b84b..137ba49e34e 100644 --- a/lib/Command/Bot/Remove.php +++ b/lib/Command/Bot/Remove.php @@ -9,7 +9,13 @@ namespace OCA\Talk\Command\Bot; use OC\Core\Command\Base; +use OCA\Talk\Events\BotDisabledEvent; +use OCA\Talk\Exceptions\RoomNotFoundException; +use OCA\Talk\Manager; use OCA\Talk\Model\BotConversationMapper; +use OCA\Talk\Model\BotServerMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\EventDispatcher\IEventDispatcher; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -17,6 +23,9 @@ class Remove extends Base { public function __construct( private BotConversationMapper $botConversationMapper, + private BotServerMapper $botServerMapper, + private IEventDispatcher $dispatcher, + private Manager $roomManager, ) { parent::__construct(); } @@ -43,9 +52,26 @@ protected function execute(InputInterface $input, OutputInterface $output): int $botId = (int) $input->getArgument('bot-id'); $tokens = $input->getArgument('token'); - $this->botConversationMapper->deleteByBotIdAndTokens($botId, $tokens); + try { + $botServer = $this->botServerMapper->findById($botId); + } catch (DoesNotExistException) { + $output->writeln('Bot could not be found by id: ' . $botId . ''); + return 1; + } + $this->botConversationMapper->deleteByBotIdAndTokens($botId, $tokens); $output->writeln('Remove bot from given conversations'); + + foreach ($tokens as $token) { + try { + $room = $this->roomManager->getRoomByToken($token); + } catch(RoomNotFoundException) { + continue; + } + $event = new BotDisabledEvent($room, $botServer); + $this->dispatcher->dispatchTyped($event); + } + return 0; } } diff --git a/lib/Command/Bot/Setup.php b/lib/Command/Bot/Setup.php index d07f70187ae..bb66955f569 100644 --- a/lib/Command/Bot/Setup.php +++ b/lib/Command/Bot/Setup.php @@ -9,6 +9,7 @@ namespace OCA\Talk\Command\Bot; use OC\Core\Command\Base; +use OCA\Talk\Events\BotEnabledEvent; use OCA\Talk\Exceptions\RoomNotFoundException; use OCA\Talk\Manager; use OCA\Talk\Model\Bot; @@ -17,6 +18,7 @@ use OCA\Talk\Model\BotServerMapper; use OCP\AppFramework\Db\DoesNotExistException; use OCP\DB\Exception; +use OCP\EventDispatcher\IEventDispatcher; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -26,6 +28,7 @@ public function __construct( private Manager $roomManager, private BotServerMapper $botServerMapper, private BotConversationMapper $botConversationMapper, + private IEventDispatcher $dispatcher, ) { parent::__construct(); } @@ -53,7 +56,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $tokens = $input->getArgument('token'); try { - $this->botServerMapper->findById($botId); + $botServer = $this->botServerMapper->findById($botId); } catch (DoesNotExistException) { $output->writeln('Bot could not be found by id: ' . $botId . ''); return 1; @@ -67,10 +70,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($room->isFederatedConversation()) { $output->writeln('Federated conversations can not have bots: ' . $token . ''); $returnCode = 2; + continue; } } catch (RoomNotFoundException) { $output->writeln('Conversation could not be found by token: ' . $token . ''); $returnCode = 2; + continue; } $bot = new BotConversation(); @@ -81,6 +86,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $this->botConversationMapper->insert($bot); $output->writeln('Successfully set up for conversation ' . $token . ''); + + $event = new BotEnabledEvent($room, $botServer); + $this->dispatcher->dispatchTyped($event); } catch (\Exception $e) { if ($e instanceof Exception && $e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { $output->writeln('Bot is already set up for the conversation ' . $token . ''); diff --git a/lib/Controller/BotController.php b/lib/Controller/BotController.php index 1f934f464b1..4d53d8f8801 100644 --- a/lib/Controller/BotController.php +++ b/lib/Controller/BotController.php @@ -11,6 +11,8 @@ use OCA\Talk\Chat\ChatManager; use OCA\Talk\Chat\ReactionManager; +use OCA\Talk\Events\BotDisabledEvent; +use OCA\Talk\Events\BotEnabledEvent; use OCA\Talk\Exceptions\ReactionAlreadyExistsException; use OCA\Talk\Exceptions\ReactionNotSupportedException; use OCA\Talk\Exceptions\ReactionOutOfContextException; @@ -37,6 +39,7 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\Comments\MessageTooLongException; use OCP\Comments\NotFoundException; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; use Psr\Log\LoggerInterface; @@ -58,6 +61,7 @@ public function __construct( protected Manager $manager, protected ReactionManager $reactionManager, protected LoggerInterface $logger, + private IEventDispatcher $dispatcher, ) { parent::__construct($appName, $request); } @@ -370,6 +374,10 @@ public function enableBot(int $botId): DataResponse { $conversationBot->setState(Bot::STATE_ENABLED); $this->botConversationMapper->insert($conversationBot); + + $event = new BotEnabledEvent($this->room, $bot); + $this->dispatcher->dispatchTyped($event); + return new DataResponse($this->formatBot($bot, true), Http::STATUS_CREATED); } @@ -400,6 +408,10 @@ public function disableBot(int $botId): DataResponse { } $this->botConversationMapper->deleteByBotIdAndTokens($botId, [$this->room->getToken()]); + + $event = new BotDisabledEvent($this->room, $bot); + $this->dispatcher->dispatchTyped($event); + return new DataResponse($this->formatBot($bot, false), Http::STATUS_OK); } diff --git a/lib/Events/BotDisabledEvent.php b/lib/Events/BotDisabledEvent.php new file mode 100644 index 00000000000..69e973ef1f7 --- /dev/null +++ b/lib/Events/BotDisabledEvent.php @@ -0,0 +1,26 @@ +botServer; + } +} diff --git a/lib/Events/BotEnabledEvent.php b/lib/Events/BotEnabledEvent.php new file mode 100644 index 00000000000..af0a3012b47 --- /dev/null +++ b/lib/Events/BotEnabledEvent.php @@ -0,0 +1,26 @@ +botServer; + } +} diff --git a/lib/Listener/BotListener.php b/lib/Listener/BotListener.php index 2ebd3e47747..d08c64a4f0b 100644 --- a/lib/Listener/BotListener.php +++ b/lib/Listener/BotListener.php @@ -10,6 +10,8 @@ namespace OCA\Talk\Listener; use OCA\Talk\Chat\MessageParser; +use OCA\Talk\Events\BotDisabledEvent; +use OCA\Talk\Events\BotEnabledEvent; use OCA\Talk\Events\BotInstallEvent; use OCA\Talk\Events\BotUninstallEvent; use OCA\Talk\Events\ChatMessageSentEvent; @@ -47,17 +49,24 @@ public function handle(Event $event): void { return; } - /** @var BotService $service */ - $service = Server::get(BotService::class); + if ($event instanceof BotEnabledEvent) { + $this->botService->afterBotEnabled($event); + return; + } + if ($event instanceof BotDisabledEvent) { + $this->botService->afterBotDisabled($event); + return; + } + /** @var MessageParser $messageParser */ $messageParser = Server::get(MessageParser::class); if ($event instanceof ChatMessageSentEvent) { - $service->afterChatMessageSent($event, $messageParser); + $this->botService->afterChatMessageSent($event, $messageParser); return; } if ($event instanceof SystemMessageSentEvent) { - $service->afterSystemMessageSent($event, $messageParser); + $this->botService->afterSystemMessageSent($event, $messageParser); } } diff --git a/lib/Service/BotService.php b/lib/Service/BotService.php index 03812698ebe..4778d9befb8 100644 --- a/lib/Service/BotService.php +++ b/lib/Service/BotService.php @@ -10,12 +10,15 @@ namespace OCA\Talk\Service; use OCA\Talk\Chat\MessageParser; +use OCA\Talk\Events\BotDisabledEvent; +use OCA\Talk\Events\BotEnabledEvent; use OCA\Talk\Events\ChatMessageSentEvent; use OCA\Talk\Events\SystemMessageSentEvent; use OCA\Talk\Model\Attendee; use OCA\Talk\Model\Bot; use OCA\Talk\Model\BotConversation; use OCA\Talk\Model\BotConversationMapper; +use OCA\Talk\Model\BotServer; use OCA\Talk\Model\BotServerMapper; use OCA\Talk\Room; use OCA\Talk\TalkSession; @@ -51,6 +54,38 @@ public function __construct( ) { } + public function afterBotEnabled(BotEnabledEvent $event): void { + $this->sendAsyncRequest($event->getBotServer(), [ + 'type' => 'Join', + 'actor' => [ + 'type' => 'Application', + 'id' => Attendee::ACTOR_BOTS . '/' . Attendee::ACTOR_BOT_PREFIX . $event->getBotServer()->getUrlHash(), + 'name' => $event->getBotServer()->getName(), + ], + 'object' => [ + 'type' => 'Collection', + 'id' => $event->getRoom()->getToken(), + 'name' => $event->getRoom()->getName(), + ], + ]); + } + + public function afterBotDisabled(BotDisabledEvent $event): void { + $this->sendAsyncRequest($event->getBotServer(), [ + 'type' => 'Leave', + 'actor' => [ + 'type' => 'Application', + 'id' => Attendee::ACTOR_BOTS . '/' . Attendee::ACTOR_BOT_PREFIX . $event->getBotServer()->getUrlHash(), + 'name' => $event->getBotServer()->getName(), + ], + 'object' => [ + 'type' => 'Collection', + 'id' => $event->getRoom()->getToken(), + 'name' => $event->getRoom()->getName(), + ], + ]); + } + public function afterChatMessageSent(ChatMessageSentEvent $event, MessageParser $messageParser): void { $attendee = $event->getParticipant()?->getAttendee(); if (!$attendee instanceof Attendee) { @@ -138,52 +173,62 @@ public function afterSystemMessageSent(SystemMessageSentEvent $event, MessagePar } /** - * @param Bot[] $bots + * @param BotServer $botServer * @param array $body + * #param string|null $jsonBody */ - protected function sendAsyncRequests(array $bots, array $body): void { - $jsonBody = json_encode($body, JSON_THROW_ON_ERROR); + protected function sendAsyncRequest(BotServer $botServer, array $body, ?string $jsonBody = null): void { + $jsonBody = $jsonBody ?? json_encode($body, JSON_THROW_ON_ERROR); + + $random = $this->secureRandom->generate(64); + $hash = hash_hmac('sha256', $random . $jsonBody, $botServer->getSecret()); + $headers = [ + 'Content-Type' => 'application/json', + 'X-Nextcloud-Talk-Random' => $random, + 'X-Nextcloud-Talk-Signature' => $hash, + 'X-Nextcloud-Talk-Backend' => rtrim($this->serverConfig->getSystemValueString('overwrite.cli.url'), '/') . '/', + 'OCS-APIRequest' => 'true', + ]; - foreach ($bots as $bot) { - $botServer = $bot->getBotServer(); - $random = $this->secureRandom->generate(64); - $hash = hash_hmac('sha256', $random . $jsonBody, $botServer->getSecret()); - $headers = [ - 'Content-Type' => 'application/json', - 'X-Nextcloud-Talk-Random' => $random, - 'X-Nextcloud-Talk-Signature' => $hash, - 'X-Nextcloud-Talk-Backend' => rtrim($this->serverConfig->getSystemValueString('overwrite.cli.url'), '/') . '/', - 'OCS-APIRequest' => 'true', - ]; + $data = [ + 'verify' => $this->certificateManager->getAbsoluteBundlePath(), + 'nextcloud' => [ + 'allow_local_address' => true, + ], + 'headers' => $headers, + 'timeout' => 5, + 'body' => $jsonBody, + ]; - $data = [ - 'verify' => $this->certificateManager->getAbsoluteBundlePath(), - 'nextcloud' => [ - 'allow_local_address' => true, - ], - 'headers' => $headers, - 'timeout' => 5, - 'body' => json_encode($body), - ]; + $client = $this->clientService->newClient(); + $promise = $client->postAsync($botServer->getUrl(), $data); - $client = $this->clientService->newClient(); - $promise = $client->postAsync($botServer->getUrl(), $data); - - $promise->then(function (IResponse $response) use ($botServer) { - if ($response->getStatusCode() !== Http::STATUS_OK && $response->getStatusCode() !== Http::STATUS_ACCEPTED) { - $this->logger->error('Bot responded with unexpected status code (Received: ' . $response->getStatusCode() . '), increasing error count'); - $botServer->setErrorCount($botServer->getErrorCount() + 1); - $botServer->setLastErrorDate($this->timeFactory->now()); - $botServer->setLastErrorMessage('UnexpectedStatusCode: ' . $response->getStatusCode()); - $this->botServerMapper->update($botServer); - } - }, function (\Exception $exception) use ($botServer) { - $this->logger->error('Bot error occurred, increasing error count', ['exception' => $exception]); + $promise->then(function (IResponse $response) use ($botServer) { + if ($response->getStatusCode() !== Http::STATUS_OK && $response->getStatusCode() !== Http::STATUS_ACCEPTED) { + $this->logger->error('Bot responded with unexpected status code (Received: ' . $response->getStatusCode() . '), increasing error count'); $botServer->setErrorCount($botServer->getErrorCount() + 1); $botServer->setLastErrorDate($this->timeFactory->now()); - $botServer->setLastErrorMessage(get_class($exception) . ': ' . $exception->getMessage()); + $botServer->setLastErrorMessage('UnexpectedStatusCode: ' . $response->getStatusCode()); $this->botServerMapper->update($botServer); - }); + } + }, function (\Exception $exception) use ($botServer) { + $this->logger->error('Bot error occurred, increasing error count', ['exception' => $exception]); + $botServer->setErrorCount($botServer->getErrorCount() + 1); + $botServer->setLastErrorDate($this->timeFactory->now()); + $botServer->setLastErrorMessage(get_class($exception) . ': ' . $exception->getMessage()); + $this->botServerMapper->update($botServer); + }); + } + + /** + * @param Bot[] $bots + * @param array $body + */ + protected function sendAsyncRequests(array $bots, array $body): void { + $jsonBody = json_encode($body, JSON_THROW_ON_ERROR); + + foreach ($bots as $bot) { + $this->sendAsyncRequest($bot->getBotServer(), $body, $jsonBody); } }