request->getHeader('x-nextcloud-talk-bot-random'); if (empty($random) || strlen($random) < 32) { $this->logger->error('Invalid Random received from bot response'); 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'); throw new \InvalidArgumentException('Invalid Signature received from bot response', Http::STATUS_BAD_REQUEST); } $bots = $this->botService->getBotsForToken($token, Bot::FEATURE_RESPONSE); foreach ($bots as $botAttempt) { try { $this->checksumVerificationService->validateRequest( $random, $checksum, $botAttempt->getBotServer()->getSecret(), $message ); if (!($botAttempt->getBotServer()->getFeatures() & Bot::FEATURE_RESPONSE)) { $this->logger->debug('Not accepting response from bot ID ' . $botAttempt->getBotServer()->getId() . ' because the feature is disabled for it'); throw new \InvalidArgumentException('Feature not enabled for bot', Http::STATUS_BAD_REQUEST); } return $botAttempt; } catch (UnauthorizedException) { } } $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 * * 201: Message sent successfully * 400: When the replyTo is invalid or message is empty * 401: Sending message is not allowed * 413: Message too long */ #[BruteForceProtection(action: 'bot')] #[OpenAPI(scope: 'bots')] #[PublicPage] #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/bot/{token}/message', requirements: [ 'apiVersion' => '(v1)', 'token' => '[a-z0-9]{4,30}', ])] public function sendMessage(string $token, string $message, string $referenceId = '', int $replyTo = 0, bool $silent = false): DataResponse { if (trim($message) === '') { return new DataResponse(null, Http::STATUS_BAD_REQUEST); } try { $bot = $this->getBotFromHeaders($token, $message); } catch (\InvalidArgumentException $e) { /** @var Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED $status */ $status = $e->getCode(); $response = new DataResponse(null, $status); 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(); $parent = null; if ($replyTo !== 0) { try { $parent = $this->chatManager->getParentComment($room, (string)$replyTo); } catch (NotFoundException $e) { // Someone is trying to reply cross-rooms or to a non-existing message return new DataResponse(null, Http::STATUS_BAD_REQUEST); } } $this->participantService->ensureOneToOneRoomIsFilled($room); $creationDateTime = $this->timeFactory->getDateTime('now', new \DateTimeZone('UTC')); try { $this->chatManager->sendMessage($room, null, $actorType, $actorId, $message, $creationDateTime, $parent, $referenceId, $silent, rateLimitGuestMentions: false); } catch (MessageTooLongException) { return new DataResponse(null, Http::STATUS_REQUEST_ENTITY_TOO_LARGE); } catch (\Exception) { return new DataResponse(null, Http::STATUS_BAD_REQUEST); } return new DataResponse(null, Http::STATUS_CREATED); } /** * Adds a reaction to a chat message * * @param string $token Conversation token * @param int $messageId ID of the message * @param string $reaction Reaction to add * @return DataResponse * * 200: Reaction already exists * 201: Reacted successfully * 400: Reacting is not possible * 401: Reacting is not allowed * 404: Reaction not found */ #[BruteForceProtection(action: 'bot')] #[OpenAPI(scope: 'bots')] #[PublicPage] #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/bot/{token}/reaction/{messageId}', requirements: [ 'apiVersion' => '(v1)', 'token' => '[a-z0-9]{4,30}', 'messageId' => '[0-9]+', ])] public function react(string $token, int $messageId, string $reaction): DataResponse { try { $bot = $this->getBotFromHeaders($token, $reaction); } catch (\InvalidArgumentException $e) { /** @var Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED $status */ $status = $e->getCode(); $response = new DataResponse(null, $status); 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, $bot->getBotServer()->getName(), $messageId, $reaction ); } catch (NotFoundException) { return new DataResponse(null, Http::STATUS_NOT_FOUND); } catch (ReactionAlreadyExistsException) { return new DataResponse(null, Http::STATUS_OK); } catch (ReactionNotSupportedException|ReactionOutOfContextException|\Exception) { return new DataResponse(null, Http::STATUS_BAD_REQUEST); } return new DataResponse(null, Http::STATUS_CREATED); } /** * Deletes a reaction from a chat message * * @param string $token Conversation token * @param int $messageId ID of the message * @param string $reaction Reaction to delete * @return DataResponse * * 200: Reaction deleted successfully * 400: Reacting is not possible * 401: Reacting is not allowed * 404: Reaction not found */ #[BruteForceProtection(action: 'bot')] #[OpenAPI(scope: 'bots')] #[PublicPage] #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/bot/{token}/reaction/{messageId}', requirements: [ 'apiVersion' => '(v1)', 'token' => '[a-z0-9]{4,30}', 'messageId' => '[0-9]+', ])] public function deleteReaction(string $token, int $messageId, string $reaction): DataResponse { try { $bot = $this->getBotFromHeaders($token, $reaction); } catch (\InvalidArgumentException $e) { /** @var Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED $status */ $status = $e->getCode(); $response = new DataResponse(null, $status); 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, $bot->getBotServer()->getName(), $messageId, $reaction ); } catch (ReactionNotSupportedException|ReactionOutOfContextException|NotFoundException) { return new DataResponse(null, Http::STATUS_NOT_FOUND); } catch (\Exception) { return new DataResponse(null, Http::STATUS_BAD_REQUEST); } return new DataResponse(null, Http::STATUS_OK); } /** * List admin bots * * @return DataResponse, array{}> * * 200: Bot list returned */ #[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['settings'])] #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/bot/admin', requirements: [ 'apiVersion' => '(v1)', ])] public function adminListBots(): DataResponse { $data = []; $bots = $this->botServerMapper->getAllBots(); foreach ($bots as $bot) { $botData = $bot->jsonSerialize(); unset($botData['secret']); $data[] = $botData; } return new DataResponse($data); } /** * List bots * * @return DataResponse, array{}> * * 200: Bot list returned */ #[NoAdminRequired] #[RequireLoggedInModeratorParticipant] #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/bot/{token}', requirements: [ 'apiVersion' => '(v1)', 'token' => '[a-z0-9]{4,30}', ])] public function listBots(): DataResponse { $alreadyInstalled = array_map(static function (BotConversation $bot): int { return $bot->getBotId(); }, $this->botConversationMapper->findForToken($this->room->getToken())); $data = []; $bots = $this->botServerMapper->getAllBots(); foreach ($bots as $bot) { $botData = $this->formatBot($bot, in_array($bot->getId(), $alreadyInstalled, true)); if ($botData !== null) { $data[] = $botData; } } return new DataResponse($data); } /** * Enables a bot * * @param int $botId ID of the bot * @return DataResponse|DataResponse * * 200: Bot already enabled * 201: Bot enabled successfully * 400: Enabling bot errored */ #[NoAdminRequired] #[RequireLoggedInModeratorParticipant] #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/bot/{token}/{botId}', requirements: [ 'apiVersion' => '(v1)', 'token' => '[a-z0-9]{4,30}', 'botId' => '[0-9]+', ])] public function enableBot(int $botId): DataResponse { if ($this->room->isFederatedConversation() || $this->room->getType() === ROOM::TYPE_ONE_TO_ONE_FORMER) { return new DataResponse([ 'error' => 'room', ], Http::STATUS_BAD_REQUEST); } try { $bot = $this->botServerMapper->findById($botId); } catch (DoesNotExistException) { return new DataResponse([ 'error' => 'bot', ], Http::STATUS_BAD_REQUEST); } if ($bot->getState() !== Bot::STATE_ENABLED) { return new DataResponse([ 'error' => 'bot', ], Http::STATUS_BAD_REQUEST); } $alreadyInstalled = array_map(static function (BotConversation $bot): int { return $bot->getBotId(); }, $this->botConversationMapper->findForToken($this->room->getToken())); if (in_array($botId, $alreadyInstalled)) { return new DataResponse($this->formatBot($bot, true), Http::STATUS_OK); } $conversationBot = new BotConversation(); $conversationBot->setBotId($botId); $conversationBot->setToken($this->room->getToken()); $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); } /** * Disables a bot * * @param int $botId ID of the bot * @return DataResponse|DataResponse * * 200: Bot disabled successfully * 400: Disabling bot errored */ #[NoAdminRequired] #[RequireLoggedInModeratorParticipant] #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/bot/{token}/{botId}', requirements: [ 'apiVersion' => '(v1)', 'token' => '[a-z0-9]{4,30}', 'botId' => '[0-9]+', ])] public function disableBot(int $botId): DataResponse { try { $bot = $this->botServerMapper->findById($botId); } catch (DoesNotExistException) { return new DataResponse([ 'error' => 'bot', ], Http::STATUS_BAD_REQUEST); } if ($bot->getState() !== Bot::STATE_ENABLED) { return new DataResponse([ 'error' => 'bot', ], Http::STATUS_BAD_REQUEST); } $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); } /** * @param BotServer $bot * @param bool $conversationEnabled * @return array|null * @psalm-return ?TalkBot */ protected function formatBot(BotServer $bot, bool $conversationEnabled): ?array { $state = $conversationEnabled ? Bot::STATE_ENABLED : Bot::STATE_DISABLED; if ($bot->getState() === Bot::STATE_NO_SETUP) { if ($state === Bot::STATE_DISABLED) { return null; } $state = Bot::STATE_NO_SETUP; } return [ 'id' => $bot->getId(), 'name' => $bot->getName(), 'description' => $bot->getDescription(), 'state' => $state, ]; } }