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);
}
}