Files
nextcloud-spreed/lib/Controller/BotController.php
2024-06-28 15:21:05 +05:30

442 lines
14 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
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;
use OCA\Talk\Exceptions\UnauthorizedException;
use OCA\Talk\Manager;
use OCA\Talk\Middleware\Attribute\RequireLoggedInModeratorParticipant;
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\ResponseDefinitions;
use OCA\Talk\Service\BotService;
use OCA\Talk\Service\ChecksumVerificationService;
use OCA\Talk\Service\ParticipantService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Comments\MessageTooLongException;
use OCP\Comments\NotFoundException;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type TalkBot from ResponseDefinitions
* @psalm-import-type TalkBotWithDetails from ResponseDefinitions
*/
class BotController extends AEnvironmentAwareController {
public function __construct(
string $appName,
IRequest $request,
protected ChatManager $chatManager,
protected ParticipantService $participantService,
protected ITimeFactory $timeFactory,
protected ChecksumVerificationService $checksumVerificationService,
protected BotConversationMapper $botConversationMapper,
protected BotServerMapper $botServerMapper,
protected BotService $botService,
protected Manager $manager,
protected ReactionManager $reactionManager,
protected LoggerInterface $logger,
private IEventDispatcher $dispatcher,
) {
parent::__construct($appName, $request);
}
/**
* @param string $token
* @param string $message
* @return Bot
* @throws \InvalidArgumentException When the request could not be linked with a bot
*/
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');
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<Http::STATUS_CREATED|Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED|Http::STATUS_REQUEST_ENTITY_TOO_LARGE, array<empty>, array{}>
*
* 201: Message sent successfully
* 400: When the replyTo is invalid or message is empty
* 401: Sending message is not allowed
* 404: Room or session not found
* 413: Message too long
*/
#[BruteForceProtection(action: 'bot')]
#[OpenAPI(scope: 'bots')]
#[PublicPage]
public function sendMessage(string $token, string $message, string $referenceId = '', int $replyTo = 0, bool $silent = false): DataResponse {
if (trim($message) === '') {
return new DataResponse([], 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([], $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([], Http::STATUS_BAD_REQUEST);
}
}
$this->participantService->ensureOneToOneRoomIsFilled($room);
$creationDateTime = $this->timeFactory->getDateTime('now', new \DateTimeZone('UTC'));
try {
$this->chatManager->sendMessage($room, $this->participant, $actorType, $actorId, $message, $creationDateTime, $parent, $referenceId, $silent, rateLimitGuestMentions: false);
} catch (MessageTooLongException) {
return new DataResponse([], Http::STATUS_REQUEST_ENTITY_TOO_LARGE);
} catch (\Exception) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
return new DataResponse([], 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<Http::STATUS_OK|Http::STATUS_CREATED|Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND, array<empty>, array{}>
*
* 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]
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([], $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,
$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);
}
/**
* 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<Http::STATUS_OK|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_UNAUTHORIZED, array<empty>, array{}>
*
* 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]
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([], $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,
$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);
}
/**
* List admin bots
*
* @return DataResponse<Http::STATUS_OK, TalkBotWithDetails[], array{}>
*
* 200: Bot list returned
*/
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['settings'])]
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<Http::STATUS_OK, TalkBot[], array{}>
*
* 200: Bot list returned
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
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<Http::STATUS_OK|Http::STATUS_CREATED, ?TalkBot, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Bot already enabled
* 201: Bot enabled successfully
* 400: Enabling bot errored
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function enableBot(int $botId): DataResponse {
if ($this->room->isFederatedConversation()) {
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<Http::STATUS_OK, ?TalkBot, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Bot disabled successfully
* 400: Disabling bot errored
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
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,
];
}
}