Skip to content

Commit

Permalink
Merge pull request #10289 from nextcloud/feat/10256/bot-features
Browse files Browse the repository at this point in the history
feat(bots): Add feature flags aka permissions to bots
  • Loading branch information
nickvergessen authored Aug 21, 2023
2 parents 8acc05d + 49e0d7c commit d246714
Show file tree
Hide file tree
Showing 12 changed files with 204 additions and 16 deletions.
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ And in the works for the [coming versions](https://github.com/nextcloud/spreed/m
]]></description>

<version>18.0.0-dev.3</version>
<version>18.0.0-dev.4</version>
<licence>agpl</licence>

<author>Daniel Calviño Sánchez</author>
Expand Down
8 changes: 5 additions & 3 deletions docs/occ.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Install a new bot on the server

### Usage

* `talk:bot:install [--output [OUTPUT]] [--no-setup] [--] <name> <secret> <url> [<description>]`
* `talk:bot:install [--output [OUTPUT]] [--no-setup] [-f|--features FEATURES] [--] <name> <secret> <url> [<description>]`

| Arguments | Description | Is required | Is array | Default |
|---|---|---|---|---|
Expand All @@ -19,6 +19,7 @@ Install a new bot on the server
|---|---|---|---|---|
| `--output` | Output format (plain, json or json_pretty, default is plain) | yes | no | no | 'plain'` |
| `--no-setup` | Prevent moderators from setting up the bot in a conversation | no | no | no | false` |
| `--features|-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 - none: When all features should be disabled for the bot | yes | yes | yes | array ()` |

## talk:bot:list

Expand Down Expand Up @@ -55,11 +56,11 @@ Remove a bot from a conversation

## talk:bot:state

List all installed bots of the server or a conversation
Change the state or feature list for a bot

### Usage

* `talk:bot:state [--output [OUTPUT]] [--] <bot-id> <state>`
* `talk:bot:state [--output [OUTPUT]] [-f|--feature FEATURE] [--] <bot-id> <state>`

| Arguments | Description | Is required | Is array | Default |
|---|---|---|---|---|
Expand All @@ -69,6 +70,7 @@ List all installed bots of the server or a conversation
| Options | Accept value | Is value required | Is multiple | Default |
|---|---|---|---|---|
| `--output` | Output format (plain, json or json_pretty, default is plain) | yes | no | no | 'plain'` |
| `--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 - none: When all features should be disabled for the bot | yes | yes | yes | array ()` |

## talk:bot:setup

Expand Down
16 changes: 16 additions & 0 deletions lib/Command/Bot/Install.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ protected function configure(): void {
InputOption::VALUE_NONE,
'Prevent moderators from setting up the bot in a conversation'
)
->addOption(
'features',
'f',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Specify the list of features for the bot' . "\n"
. ' - webhook: The bot receives posted chat messages as webhooks' . "\n"
. ' - response: The bot can post messages and reactions as a response' . "\n"
. ' - none: When all features should be disabled for the bot'
)
;
}

Expand All @@ -82,13 +91,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$description = $input->getArgument('description');
$noSetup = $input->getOption('no-setup');

if (!empty($input->getOption('feature'))) {
$featureFlags = Bot::featureLabelsToFlags($input->getOption('feature'));
} else {
$featureFlags = Bot::FEATURE_WEBHOOK + Bot::FEATURE_RESPONSE;
}

$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 {
$this->botServerMapper->insert($bot);
} catch (\Exception $e) {
Expand Down
2 changes: 2 additions & 0 deletions lib/Command/Bot/ListBots.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
namespace OCA\Talk\Command\Bot;

use OC\Core\Command\Base;
use OCA\Talk\Model\Bot;
use OCA\Talk\Model\BotConversation;
use OCA\Talk\Model\BotConversationMapper;
use OCA\Talk\Model\BotServerMapper;
Expand Down Expand Up @@ -71,6 +72,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}

$botData = $bot->jsonSerialize();
$botData['features'] = Bot::featureFlagsToLabels($botData['features']);

if (!$output->isVerbose()) {
unset($botData['url']);
Expand Down
30 changes: 26 additions & 4 deletions lib/Command/Bot/State.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
use OCP\AppFramework\Db\DoesNotExistException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class State extends Base {
Expand All @@ -44,7 +45,7 @@ protected function configure(): void {
parent::configure();
$this
->setName('talk:bot:state')
->setDescription('List all installed bots of the server or a conversation')
->setDescription('Change the state or feature list for a bot')
->addArgument(
'bot-id',
InputArgument::REQUIRED,
Expand All @@ -55,12 +56,26 @@ protected function configure(): void {
InputArgument::REQUIRED,
'New state for the bot (0 = disabled, 1 = enabled, 2 = no setup via GUI)'
)
->addOption(
'feature',
'f',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Specify the list of features for the bot' . "\n"
. ' - webhook: The bot receives posted chat messages as webhooks' . "\n"
. ' - response: The bot can post messages and reactions as a response' . "\n"
. ' - none: When all features should be disabled for the bot'
)
;
}

protected function execute(InputInterface $input, OutputInterface $output): int {
$botId = (int) $input->getArgument('bot-id');
$state = (int) $input->getArgument('state');
$botId = (int)$input->getArgument('bot-id');
$state = (int)$input->getArgument('state');

$featureFlags = null;
if (!empty($input->getOption('feature'))) {
$featureFlags = Bot::featureLabelsToFlags($input->getOption('feature'));
}

if (!in_array($state, [Bot::STATE_DISABLED, Bot::STATE_ENABLED, Bot::STATE_NO_SETUP], true)) {
$output->writeln('<error>Provided state is invalid</error>');
Expand All @@ -75,9 +90,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}

$bot->setState($state);
if ($featureFlags !== null) {
$bot->setFeatures($featureFlags);
}
$this->botServerMapper->update($bot);

$output->writeln('<info>Bot state set to ' . $state . '</info>');
if ($featureFlags !== null) {
$output->writeln('<info>Bot state set to ' . $state . ' with features: ' . Bot::featureFlagsToLabels($featureFlags) . '</info>');
} else {
$output->writeln('<info>Bot state set to ' . $state . '</info>');
}
return 0;
}
}
6 changes: 6 additions & 0 deletions lib/Controller/BotController.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ protected function getBotFromHeaders(string $token, string $message): Bot {
$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) {
}
Expand Down
55 changes: 55 additions & 0 deletions lib/Migration/Version18000Date20230821112014.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);
/**
* @copyright Copyright (c) 2023, Joas Schilling <coding@schilljs.com>
*
* @author Joas Schilling <coding@schilljs.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

namespace OCA\Talk\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

class Version18000Date20230821112014 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

$table = $schema->getTable('talk_bots_server');
if (!$table->hasColumn('features')) {
$table->addColumn('features', Types::INTEGER, [
'default' => 3, // webhook + response
'unsigned' => true,
]);
return $schema;
}
return null;
}
}
43 changes: 43 additions & 0 deletions lib/Model/Bot.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ class Bot {
public const STATE_ENABLED = 1;
public const STATE_NO_SETUP = 2;

public const FEATURE_NONE = 0;
public const FEATURE_WEBHOOK = 1;
public const FEATURE_RESPONSE = 2;

public const FEATURE_LABEL_NONE = 'none';
public const FEATURE_LABEL_WEBHOOK = 'webhook';
public const FEATURE_LABEL_RESPONSE = 'response';

public const FEATURE_MAP = [
self::FEATURE_NONE => self::FEATURE_LABEL_NONE,
self::FEATURE_WEBHOOK => self::FEATURE_LABEL_WEBHOOK,
self::FEATURE_RESPONSE => self::FEATURE_LABEL_RESPONSE,
];

public function __construct(
protected BotServer $botServer,
protected BotConversation $botConversation,
Expand All @@ -49,4 +63,33 @@ public function isEnabled(): bool {
return $this->botServer->getState() !== self::STATE_DISABLED
&& $this->botConversation->getState() !== self::STATE_DISABLED;
}

public static function featureFlagsToLabels(int $flags): string {
if ($flags === self::FEATURE_NONE) {
return self::FEATURE_LABEL_NONE;
}

$features = [];
foreach (self::FEATURE_MAP as $flag => $label) {
if ($flags & $flag) {
$features[] = $label;
}
}
return implode(', ', $features);
}

public static function featureLabelsToFlags(array $labels): int {
$reverseMap = array_flip(self::FEATURE_MAP);
$flags = 0;
foreach ($labels as $label) {
if ($label === self::FEATURE_LABEL_NONE) {
return self::FEATURE_NONE;
}
if (isset($reverseMap[$label])) {
$flags += $reverseMap[$label];
}
}

return $flags;
}
}
5 changes: 5 additions & 0 deletions lib/Model/BotServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
* @method string getLastErrorMessage()
* @method void setState(int $state)
* @method int getState()
* @method void setFeatures(int $features)
* @method int getFeatures()
*/
class BotServer extends Entity implements \JsonSerializable {
protected string $name = '';
Expand All @@ -55,6 +57,7 @@ class BotServer extends Entity implements \JsonSerializable {
protected ?\DateTimeImmutable $lastErrorDate = null;
protected ?string $lastErrorMessage = null;
protected int $state = Bot::STATE_DISABLED;
protected int $features = Bot::FEATURE_NONE;

public function __construct() {
$this->addType('name', 'string');
Expand All @@ -66,6 +69,7 @@ public function __construct() {
$this->addType('last_error_date', 'datetime');
$this->addType('last_error_message', 'string');
$this->addType('state', 'int');
$this->addType('features', 'int');
}

public function jsonSerialize(): array {
Expand All @@ -80,6 +84,7 @@ public function jsonSerialize(): array {
'last_error_date' => $this->getLastErrorDate() ? $this->getLastErrorDate()->getTimestamp() : 0,
'last_error_message' => $this->getLastErrorMessage(),
'state' => $this->getState(),
'features' => $this->getFeatures(),
];
}
}
8 changes: 7 additions & 1 deletion lib/Service/BotService.php
Original file line number Diff line number Diff line change
Expand Up @@ -269,9 +269,15 @@ public function getBotsForToken(string $token): array {
$this->logger->warning('Can not find bot by ID ' . $botConversation->getBotId() . ' for token ' . $botConversation->getToken());
continue;
}
$botServer = $serversMap[$botConversation->getBotId()];

if (!($botServer->getFeatures() & Bot::FEATURE_WEBHOOK)) {
$this->logger->debug('Not sending webhook to bot ID ' . $botConversation->getBotId() . ' because the feature is disabled for it');
continue;
}

$bot = new Bot(
$serversMap[$botConversation->getBotId()],
$botServer,
$botConversation,
);

Expand Down
10 changes: 8 additions & 2 deletions tests/integration/features/bootstrap/FeatureContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -3518,7 +3518,7 @@ public function setupOrRemoveBotInRoom(string $action, string $botName, string $
/**
* @Then /^set state (enabled|disabled|no-setup) for bot "([^"]*)" via OCC$/
*/
public function stateUpdateForBot(string $state, string $botName): void {
public function stateUpdateForBot(string $state, string $botName, ?TableNode $body = null): void {
if ($state === 'enabled') {
$state = 1;
} elseif ($state === 'disabled') {
Expand All @@ -3527,7 +3527,13 @@ public function stateUpdateForBot(string $state, string $botName): void {
$state = 2;
}

$this->invokingTheCommand('talk:bot:state ' . self::$botNameToId[$botName] . ' ' . $state);
$features = '';
if ($body) {
$features = array_map(static fn ($map) => $map['feature'], $body->getColumnsHash());
$features = ' -f ' . implode(' -f ', $features);
}

$this->invokingTheCommand('talk:bot:state ' . self::$botNameToId[$botName] . ' ' . $state . $features);
$this->theCommandWasSuccessful();
}

Expand Down
Loading

0 comments on commit d246714

Please sign in to comment.