feat(bots): Add events for enabling and disabling bots

Signed-off-by: Sanskar Soni <sanskarsoni300@gmail.com>
This commit is contained in:
Sanskar Soni
2024-06-28 15:18:29 +05:30
parent ef390fef18
commit e8af04d557
10 changed files with 307 additions and 47 deletions

View File

@ -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`

View File

@ -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

View File

@ -43,6 +43,8 @@ use OCA\Talk\Events\BeforeRoomDeletedEvent;
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 @@ class Application extends App implements IBootstrap {
$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);

View File

@ -9,7 +9,13 @@ declare(strict_types=1);
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 @@ use Symfony\Component\Console\Output\OutputInterface;
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 @@ class Remove extends Base {
$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('<error>Bot could not be found by id: ' . $botId . '</error>');
return 1;
}
$this->botConversationMapper->deleteByBotIdAndTokens($botId, $tokens);
$output->writeln('<info>Remove bot from given conversations</info>');
foreach ($tokens as $token) {
try {
$room = $this->roomManager->getRoomByToken($token);
} catch(RoomNotFoundException) {
continue;
}
$event = new BotDisabledEvent($room, $botServer);
$this->dispatcher->dispatchTyped($event);
}
return 0;
}
}

View File

@ -9,6 +9,7 @@ declare(strict_types=1);
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\BotConversationMapper;
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 @@ class Setup extends Base {
private Manager $roomManager,
private BotServerMapper $botServerMapper,
private BotConversationMapper $botConversationMapper,
private IEventDispatcher $dispatcher,
) {
parent::__construct();
}
@ -53,7 +56,7 @@ class Setup extends Base {
$tokens = $input->getArgument('token');
try {
$this->botServerMapper->findById($botId);
$botServer = $this->botServerMapper->findById($botId);
} catch (DoesNotExistException) {
$output->writeln('<error>Bot could not be found by id: ' . $botId . '</error>');
return 1;
@ -67,10 +70,12 @@ class Setup extends Base {
if ($room->isFederatedConversation()) {
$output->writeln('<error>Federated conversations can not have bots: ' . $token . '</error>');
$returnCode = 2;
continue;
}
} catch (RoomNotFoundException) {
$output->writeln('<error>Conversation could not be found by token: ' . $token . '</error>');
$returnCode = 2;
continue;
}
$bot = new BotConversation();
@ -81,6 +86,9 @@ class Setup extends Base {
try {
$this->botConversationMapper->insert($bot);
$output->writeln('<info>Successfully set up for conversation ' . $token . '</info>');
$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('<error>Bot is already set up for the conversation ' . $token . '</error>');

View File

@ -11,6 +11,8 @@ 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;
@ -37,6 +39,7 @@ 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;
@ -58,6 +61,7 @@ class BotController extends AEnvironmentAwareController {
protected Manager $manager,
protected ReactionManager $reactionManager,
protected LoggerInterface $logger,
private IEventDispatcher $dispatcher,
) {
parent::__construct($appName, $request);
}
@ -370,6 +374,10 @@ class BotController extends AEnvironmentAwareController {
$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 @@ class BotController extends AEnvironmentAwareController {
}
$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);
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Events;
use OCA\Talk\Model\BotServer;
use OCA\Talk\Room;
class BotDisabledEvent extends ARoomEvent {
public function __construct(
Room $room,
protected BotServer $botServer,
) {
parent::__construct($room);
}
public function getBotServer(): BotServer {
return $this->botServer;
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Events;
use OCA\Talk\Model\BotServer;
use OCA\Talk\Room;
class BotEnabledEvent extends ARoomEvent {
public function __construct(
Room $room,
protected BotServer $botServer,
) {
parent::__construct($room);
}
public function getBotServer(): BotServer {
return $this->botServer;
}
}

View File

@ -10,6 +10,8 @@ declare(strict_types=1);
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 @@ class BotListener implements IEventListener {
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);
}
}

View File

@ -10,12 +10,15 @@ declare(strict_types=1);
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 @@ class BotService {
) {
}
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) {
@ -137,6 +172,54 @@ class BotService {
]);
}
/**
* @param BotServer $botServer
* @param array $body
* #param string|null $jsonBody
*/
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',
];
$data = [
'verify' => $this->certificateManager->getAbsoluteBundlePath(),
'nextcloud' => [
'allow_local_address' => true,
],
'headers' => $headers,
'timeout' => 5,
'body' => $jsonBody,
];
$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]);
$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
@ -145,45 +228,7 @@ class BotService {
$jsonBody = json_encode($body, JSON_THROW_ON_ERROR);
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' => json_encode($body),
];
$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]);
$botServer->setErrorCount($botServer->getErrorCount() + 1);
$botServer->setLastErrorDate($this->timeFactory->now());
$botServer->setLastErrorMessage(get_class($exception) . ': ' . $exception->getMessage());
$this->botServerMapper->update($botServer);
});
$this->sendAsyncRequest($bot->getBotServer(), $body, $jsonBody);
}
}