From d5acc82ed8cfd608a5acb0bea43e2972b0b53237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Mon, 6 Oct 2025 16:44:34 +0200 Subject: [PATCH] feat(bots): Add talk:bot:create command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcel Müller --- appinfo/info.xml | 1 + docs/occ.md | 19 +++ lib/Command/Bot/Create.php | 116 ++++++++++++++++++ lib/Command/Bot/State.php | 5 + lib/Model/Bot.php | 1 + lib/Service/BotService.php | 2 +- .../integration/features/chat-2/bots.feature | 10 ++ 7 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 lib/Command/Bot/Create.php diff --git a/appinfo/info.xml b/appinfo/info.xml index c576f842a7..a444696b59 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -87,6 +87,7 @@ OCA\Talk\Command\Bot\Install + OCA\Talk\Command\Bot\Create OCA\Talk\Command\Bot\ListBots OCA\Talk\Command\Bot\Remove OCA\Talk\Command\Bot\State diff --git a/docs/occ.md b/docs/occ.md index d71ce3c781..949823b1df 100644 --- a/docs/occ.md +++ b/docs/occ.md @@ -21,6 +21,25 @@ Install a new bot on the server | `--no-setup` | Prevent moderators from setting up the bot in a conversation | no | no | no | `false` | | `--feature\|-f` | Specify the list of features for the bot - webhook: The bot receives posted chat messages as webhooks - response: The bot can post messages and reactions as a response - event: The bot reads posted messages from local events - reaction: The bot is notified about adding and removing of reactions - none: When all features should be disabled for the bot | yes | yes | yes | *Required* | +## talk:bot:create + +Creates a new bot on the server with 'response' feature only. + +### Usage + +* `talk:bot:create [--output [OUTPUT]] [-s|--secret SECRET] [--no-setup] [--] []` + +| Arguments | Description | Is required | Is array | Default | +|---|---|---|---|---| +| `name` | The name under which the messages will be posted (min. 1 char, max. 64 chars) | yes | no | *Required* | +| `description` | Optional description shown in the admin settings (max. 4000 chars) | no | no | `NULL` | + +| Options | Description | Accept value | Is value required | Is multiple | Default | +|---|---|---|---|---|---| +| `--output` | Output format (plain, json or json_pretty, default is plain) | yes | no | no | `'plain'` | +| `--secret\|-s` | Secret used to validate API calls (min. 40 chars, max. 128 chars). When none is provided, a random 64 chars string is generated and output. | yes | yes | no | *Required* | +| `--no-setup` | Prevent moderators from setting up the bot in a conversation | no | no | no | `false` | + ## talk:bot:list List all installed bots of the server or a conversation diff --git a/lib/Command/Bot/Create.php b/lib/Command/Bot/Create.php new file mode 100644 index 0000000000..f77ef59a60 --- /dev/null +++ b/lib/Command/Bot/Create.php @@ -0,0 +1,116 @@ +setName('talk:bot:create') + ->setDescription('Creates a new bot on the server with \'response\' feature only.') + ->addArgument( + 'name', + InputArgument::REQUIRED, + 'The name under which the messages will be posted (min. 1 char, max. 64 chars)' + ) + ->addArgument( + 'description', + InputArgument::OPTIONAL, + 'Optional description shown in the admin settings (max. 4000 chars)' + ) + ->addOption( + 'secret', + 's', + InputOption::VALUE_REQUIRED, + 'Secret used to validate API calls (min. 40 chars, max. 128 chars). When none is provided, a random 64 chars string is generated and output.' + ) + ->addOption( + 'no-setup', + null, + InputOption::VALUE_NONE, + 'Prevent moderators from setting up the bot in a conversation' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $name = $input->getArgument('name'); + $description = $input->getArgument('description') ?? ''; + $noSetup = $input->getOption('no-setup'); + $featureFlags = Bot::FEATURE_RESPONSE; + + $secret = $input->getOption('secret') ?? $this->secureRandom->generate(64); + $url = Bot::URL_RESPONSE_ONLY_PREFIX . bin2hex(random_bytes(16)); + + try { + $this->botService->validateBotParameters($name, $secret, $url, $description); + } catch (\InvalidArgumentException $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + + try { + $this->botServerMapper->findByUrl($url); + $output->writeln('Bot with the same URL is already registered'); + return 2; + } catch (DoesNotExistException) { + } + + $bot = new BotServer(); + $bot->setName($name); + $bot->setSecret($secret); + $bot->setUrl($url); + $bot->setUrlHash(sha1($url)); + $bot->setDescription($description); + $bot->setState($noSetup ? Bot::STATE_NO_SETUP : Bot::STATE_ENABLED); + $bot->setFeatures($featureFlags); + try { + $botEntity = $this->botServerMapper->insert($bot); + } catch (\Exception $e) { + if ($e instanceof Exception && $e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + $output->writeln('Bot with the same secret is already registered'); + return 3; + } else { + $output->writeln('' . get_class($e) . ': ' . $e->getMessage() . ''); + return 1; + } + } + + $output->writeln('Bot installed'); + $output->writeln('ID: ' . $botEntity->getId()); + + if ($input->getOption('secret') === null) { + $output->writeln('Secret: ' . $secret); + } + + return 0; + } +} diff --git a/lib/Command/Bot/State.php b/lib/Command/Bot/State.php index deaeff5fc6..0c5a031022 100644 --- a/lib/Command/Bot/State.php +++ b/lib/Command/Bot/State.php @@ -77,6 +77,11 @@ class State extends Base { $bot->setState($state); if ($featureFlags !== null) { + if (str_starts_with($bot->getUrl(), Bot::URL_RESPONSE_ONLY_PREFIX)) { + $output->writeln('Feature flags of response-only bots cannot be changed'); + return 1; + } + $bot->setFeatures($featureFlags); } $this->botServerMapper->update($bot); diff --git a/lib/Model/Bot.php b/lib/Model/Bot.php index 318aecb9a1..ef390bde1d 100644 --- a/lib/Model/Bot.php +++ b/lib/Model/Bot.php @@ -26,6 +26,7 @@ class Bot { public const FEATURE_LABEL_EVENT = 'event'; public const FEATURE_LABEL_REACTION = 'reaction'; public const URL_APP_PREFIX = 'nextcloudapp://'; + public const URL_RESPONSE_ONLY_PREFIX = 'responseonly://'; public const FEATURE_MAP = [ self::FEATURE_NONE => self::FEATURE_LABEL_NONE, diff --git a/lib/Service/BotService.php b/lib/Service/BotService.php index 5d320ffa5b..912dc1aa1a 100644 --- a/lib/Service/BotService.php +++ b/lib/Service/BotService.php @@ -452,7 +452,7 @@ class BotService { throw new \InvalidArgumentException('The provided secret is too short (min. 40 chars, max. 128 chars)'); } - if (!$url || strlen($url) > 4000 || !(str_starts_with($url, 'http://') || str_starts_with($url, 'https://') || str_starts_with($url, Bot::URL_APP_PREFIX))) { + if (!$url || strlen($url) > 4000 || !(str_starts_with($url, 'http://') || str_starts_with($url, 'https://') || str_starts_with($url, Bot::URL_APP_PREFIX) || str_starts_with($url, Bot::URL_RESPONSE_ONLY_PREFIX))) { throw new \InvalidArgumentException('The provided URL is not a valid URL'); } diff --git a/tests/integration/features/chat-2/bots.feature b/tests/integration/features/chat-2/bots.feature index 51152ab710..3beb5f7751 100644 --- a/tests/integration/features/chat-2/bots.feature +++ b/tests/integration/features/chat-2/bots.feature @@ -381,3 +381,13 @@ Feature: chat/bots Given invoking occ with "talk:bot:list room-name:room" And the command was successful And the command output is empty + + Scenario: Test bot creation + Given invoking occ with "talk:bot:create Bot" + And the command was successful + And the command output contains the text "Secret:" + When invoking occ with "talk:bot:create Bot Description" + Then the command was successful + And the command output contains the text "Secret:" + When invoking occ with "talk:bot:create --secret Secret1234567890123456789012345678901234567890 Bot" + Then the command was successful