UPSTREAM BASELINE: nextcloud/spreed v22.0.12 (без изменений)
Type checking / changes (push) Has been cancelled
Type checking / test (push) Has been cancelled
Type checking / typescript-summary (push) Has been cancelled
Node tests / changes (push) Has been cancelled
Node tests / test (push) Has been cancelled
Node tests / test-summary (push) Has been cancelled

Источник: https://github.com/nextcloud/spreed/archive/refs/tags/v22.0.12.tar.gz
С этого коммита ветка официального Nextcloud Talk отрезана (решение владельца 2026-07-06).
Все дальнейшие изменения — только наши; версии релизов: 22.0.12-f7.N.
This commit is contained in:
2026-07-06 14:07:50 +00:00
commit 01acfa3b40
1716 changed files with 613013 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Bot;
use OC\Core\Command\Base;
use OCA\Talk\Model\Bot;
use OCA\Talk\Model\BotServer;
use OCA\Talk\Model\BotServerMapper;
use OCA\Talk\Service\BotService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\DB\Exception;
use OCP\Security\ISecureRandom;
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 Create extends Base {
public function __construct(
private BotService $botService,
private BotServerMapper $botServerMapper,
private ISecureRandom $secureRandom,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
parent::configure();
$this
->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('<error>' . $e->getMessage() . '</error>');
return 1;
}
try {
$this->botServerMapper->findByUrl($url);
$output->writeln('<error>Bot with the same URL is already registered</error>');
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('<error>Bot with the same secret is already registered</error>');
return 3;
} else {
$output->writeln('<error>' . get_class($e) . ': ' . $e->getMessage() . '</error>');
return 1;
}
}
$output->writeln('<info>Bot installed</info>');
$output->writeln('ID: ' . $botEntity->getId());
if ($input->getOption('secret') === null) {
$output->writeln('Secret: ' . $secret);
}
return 0;
}
}
+141
View File
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Bot;
use OC\Core\Command\Base;
use OCA\Talk\Model\Bot;
use OCA\Talk\Model\BotServer;
use OCA\Talk\Model\BotServerMapper;
use OCA\Talk\Service\BotService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\DB\Exception;
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 Install extends Base {
public function __construct(
private BotService $botService,
private BotServerMapper $botServerMapper,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
parent::configure();
$this
->setName('talk:bot:install')
->setDescription('Install a new bot on the server')
->addArgument(
'name',
InputArgument::REQUIRED,
'The name under which the messages will be posted (min. 1 char, max. 64 chars)'
)
->addArgument(
'secret',
InputArgument::REQUIRED,
'Secret used to validate API calls (min. 40 chars, max. 128 chars)'
)
->addArgument(
'url',
InputArgument::REQUIRED,
'Webhook endpoint to post messages to (max. 4000 chars)'
)
->addArgument(
'description',
InputArgument::OPTIONAL,
'Optional description shown in the admin settings (max. 4000 chars)'
)
->addOption(
'no-setup',
null,
InputOption::VALUE_NONE,
'Prevent moderators from setting up the bot in a conversation'
)
->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"
. ' - event: The bot reads posted messages from local events' . "\n"
. ' - reaction: The bot is notified about adding and removing of reactions' . "\n"
. ' - none: When all features should be disabled for the bot'
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$name = $input->getArgument('name');
$secret = $input->getArgument('secret');
$url = $input->getArgument('url');
$description = $input->getArgument('description') ?? '';
$noSetup = $input->getOption('no-setup');
if (!empty($input->getOption('feature'))) {
$featureFlags = Bot::featureLabelsToFlags($input->getOption('feature'));
if (str_starts_with($url, Bot::URL_APP_PREFIX)) {
$featureFlags &= ~Bot::FEATURE_WEBHOOK;
}
} elseif (str_starts_with($url, Bot::URL_APP_PREFIX)) {
$featureFlags = Bot::FEATURE_EVENT;
} else {
$featureFlags = Bot::FEATURE_WEBHOOK + Bot::FEATURE_RESPONSE;
}
if ($featureFlags & Bot::FEATURE_EVENT
&& ($featureFlags & Bot::FEATURE_WEBHOOK
|| $featureFlags & Bot::FEATURE_RESPONSE
|| $featureFlags & Bot::FEATURE_REACTION)) {
$output->writeln('<error>Bots with feature "event" can not support "webhook", "response" or "reaction" feature. They are mutual exclusive</error>');
return 1;
}
try {
$this->botService->validateBotParameters($name, $secret, $url, $description);
} catch (\InvalidArgumentException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
try {
$this->botServerMapper->findByUrl($url);
$output->writeln('<error>Bot with the same URL is already registered</error>');
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('<error>Bot with the same secret is already registered</error>');
return 3;
} else {
$output->writeln('<error>' . get_class($e) . ': ' . $e->getMessage() . '</error>');
return 1;
}
}
$output->writeln('<info>Bot installed</info>');
$output->writeln('ID: ' . $botEntity->getId());
return 0;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
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;
use OCA\Talk\Service\BotService;
use OCP\App\IAppManager;
use OCP\AppFramework\Utility\ITimeFactory;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ListBots extends Base {
public function __construct(
private BotConversationMapper $botConversationMapper,
private BotServerMapper $botServerMapper,
private BotService $botService,
private IAppManager $appManager,
private ITimeFactory $timeFactory,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
parent::configure();
$this
->setName('talk:bot:list')
->setDescription('List all installed bots of the server or a conversation')
->addArgument(
'token',
InputArgument::OPTIONAL,
'Conversation token to limit the bot list for'
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$bots = $this->botServerMapper->getAllBots();
$token = $input->getArgument('token');
if ($token) {
$botIds = array_map(static function (BotConversation $bot): int {
return $bot->getBotId();
}, $this->botConversationMapper->findForToken($token));
}
$data = [];
foreach ($bots as $bot) {
if ($token && !in_array($bot->getId(), $botIds, true)) {
continue;
}
$botData = $bot->jsonSerialize();
$botData['features'] = Bot::featureFlagsToLabels($botData['features']);
if (!$this->botService->isAppForBotEnabled($bot)) {
$botData['state'] = Bot::STATE_UNAVAILABLE;
if ($input->getOption('output') === 'plain') {
$botData['error_count'] = '<error>' . 1 . '</error>';
} else {
$botData['error_count'] = 1;
}
$botData['last_error_date'] = $this->timeFactory->getTime();
if ($input->getOption('output') === 'plain') {
$botData['last_error_message'] = '<error>App disabled</error>';
} else {
$botData['last_error_message'] = 'App disabled';
}
}
if (!$output->isVerbose()) {
unset($botData['url']);
unset($botData['url_hash']);
unset($botData['secret']);
unset($botData['last_error_date']);
unset($botData['last_error_message']);
}
$data[] = $botData;
}
$this->writeTableInOutputFormat($input, $output, $data);
return 0;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
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;
class Remove extends Base {
public function __construct(
private BotConversationMapper $botConversationMapper,
private BotServerMapper $botServerMapper,
private IEventDispatcher $dispatcher,
private Manager $roomManager,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
parent::configure();
$this
->setName('talk:bot:remove')
->setDescription('Remove a bot from a conversation')
->addArgument(
'bot-id',
InputArgument::REQUIRED,
'The ID of the bot to remove in a conversation'
)
->addArgument(
'token',
InputArgument::IS_ARRAY,
'Conversation tokens to remove bot up for'
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$botId = (int)$input->getArgument('bot-id');
$tokens = $input->getArgument('token');
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;
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
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;
use OCA\Talk\Model\BotConversation;
use OCA\Talk\Model\BotConversationMapper;
use OCA\Talk\Model\BotServerMapper;
use OCA\Talk\Service\BotService;
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;
class Setup extends Base {
public function __construct(
private Manager $roomManager,
private BotServerMapper $botServerMapper,
private BotConversationMapper $botConversationMapper,
private BotService $botService,
private IEventDispatcher $dispatcher,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
parent::configure();
$this
->setName('talk:bot:setup')
->setDescription('Add a bot to a conversation')
->addArgument(
'bot-id',
InputArgument::REQUIRED,
'The ID of the bot to set up in a conversation'
)
->addArgument(
'token',
InputArgument::IS_ARRAY,
'Conversation tokens to set the bot up for'
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$botId = (int)$input->getArgument('bot-id');
$tokens = $input->getArgument('token');
try {
$botServer = $this->botServerMapper->findById($botId);
} catch (DoesNotExistException) {
$output->writeln('<error>Bot could not be found by id: ' . $botId . '</error>');
return 1;
}
if (!$this->botService->isAppForBotEnabled($botServer)) {
$output->writeln('<error>Bot app is disabled: ' . $botServer->getUrl() . '</error>');
return 1;
}
$returnCode = 0;
foreach ($tokens as $token) {
try {
$room = $this->roomManager->getRoomByToken($token);
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();
$bot->setBotId($botId);
$bot->setToken($token);
$bot->setState(Bot::STATE_ENABLED);
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>');
$returnCode = 3;
} else {
$output->writeln('<error>' . get_class($e) . ': ' . $e->getMessage() . '</error>');
$returnCode = 4;
}
}
}
return $returnCode;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Bot;
use OC\Core\Command\Base;
use OCA\Talk\Model\Bot;
use OCA\Talk\Model\BotServerMapper;
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 {
public function __construct(
private BotServerMapper $botServerMapper,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
parent::configure();
$this
->setName('talk:bot:state')
->setDescription('Change the state or feature list for a bot')
->addArgument(
'bot-id',
InputArgument::REQUIRED,
'Bot ID to change the state for'
)
->addArgument(
'state',
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"
. ' - event: The bot reads posted messages from local events' . "\n"
. ' - reaction: The bot is notified about adding and removing of reactions' . "\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');
$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>');
return 1;
}
try {
$bot = $this->botServerMapper->findById($botId);
} catch (DoesNotExistException) {
$output->writeln('<error>Bot could not be found by id: ' . $botId . '</error>');
return 1;
}
$bot->setState($state);
if ($featureFlags !== null) {
if (str_starts_with($bot->getUrl(), Bot::URL_RESPONSE_ONLY_PREFIX)) {
$output->writeln('<error>Feature flags of response-only bots cannot be changed</error>');
return 1;
}
$bot->setFeatures($featureFlags);
}
$this->botServerMapper->update($bot);
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;
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Bot;
use OC\Core\Command\Base;
use OCA\Talk\Model\BotConversationMapper;
use OCA\Talk\Model\BotServerMapper;
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 Uninstall extends Base {
public function __construct(
private BotConversationMapper $botConversationMapper,
private BotServerMapper $botServerMapper,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
parent::configure();
$this
->setName('talk:bot:uninstall')
->setDescription('Uninstall a bot from the server')
->addArgument(
'id',
InputArgument::OPTIONAL,
'The ID of the bot'
)
->addOption(
'url',
null,
InputOption::VALUE_REQUIRED,
'The URL of the bot (required when no ID is given, ignored otherwise)'
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$botId = (int)$input->getArgument('id');
try {
if ($botId === 0) {
$url = $input->getOption('url');
if ($url === null) {
$output->writeln('<error>URL is required when no ID is given</error>');
return 1;
}
$bot = $this->botServerMapper->findByUrl($url);
} else {
$bot = $this->botServerMapper->findById($botId);
}
} catch (DoesNotExistException) {
$output->writeln('<error>Bot not found</error>');
return 1;
}
$this->botConversationMapper->deleteByBotId($bot->getId());
$this->botServerMapper->deleteById($bot->getId());
$output->writeln('<info>Bot uninstalled</info>');
return 0;
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Developer;
use OC\Core\Command\Base;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Manager;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IConfig;
use OCP\IDBConnection;
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 AgeChatMessages extends Base {
public function __construct(
private readonly IConfig $config,
private readonly IDBConnection $connection,
private readonly Manager $manager,
) {
parent::__construct();
}
public function isEnabled(): bool {
return $this->config->getSystemValue('debug', false) === true;
}
#[\Override]
protected function configure(): void {
$this
->setName('talk:developer:age-chat-messages')
->setDescription('Artificially ages chat messages in the given conversation, so deletion and other things can be tested')
->addArgument(
'token',
InputArgument::REQUIRED,
'Token of the room to manipulate'
)
->addOption(
'hours',
null,
InputOption::VALUE_REQUIRED,
'Number of hours to age all chat messages',
24
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$token = $input->getArgument('token');
$hours = (int)$input->getOption('hours');
if ($hours < 1) {
$output->writeln('<error>Invalid age: ' . $hours . '</error>');
return 1;
}
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException) {
$output->writeln('<error>Room not found: ' . $token . '</error>');
return 1;
}
$update = $this->connection->getQueryBuilder();
$update->update('comments')
->set('creation_timestamp', $update->createParameter('creation_timestamp'))
->set('expire_date', $update->createParameter('expire_date'))
->set('meta_data', $update->createParameter('meta_data'))
->where($update->expr()->eq('id', $update->createParameter('id')));
$query = $this->connection->getQueryBuilder();
$query->select('id', 'creation_timestamp', 'expire_date', 'meta_data')
->from('comments')
->where($query->expr()->eq('object_type', $query->createNamedParameter('chat')))
->andWhere($query->expr()->eq('object_id', $query->createNamedParameter($room->getId())));
$result = $query->executeQuery();
while ($row = $result->fetch()) {
$creationTimestamp = new \DateTime($row['creation_timestamp']);
$creationTimestamp->sub(new \DateInterval('PT' . $hours . 'H'));
$expireDate = null;
if ($row['expire_date']) {
$expireDate = new \DateTime($row['expire_date']);
$expireDate->sub(new \DateInterval('PT' . $hours . 'H'));
}
$metaData = 'null';
if ($row['meta_data'] !== 'null') {
$metaData = json_decode($row['meta_data'], true);
if (isset($metaData['last_edited_time'])) {
$metaData['last_edited_time'] -= $hours * 3600;
}
$metaData = json_encode($metaData);
}
$update->setParameter('id', $row['id']);
$update->setParameter('creation_timestamp', $creationTimestamp, IQueryBuilder::PARAM_DATE);
$update->setParameter('expire_date', $expireDate, IQueryBuilder::PARAM_DATE);
$update->setParameter('meta_data', $metaData);
$update->executeStatement();
}
$result->closeCursor();
return 0;
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Developer;
use OC\Core\Command\Base;
use OCP\App\IAppManager;
use OCP\IConfig;
use OCP\Server;
use Symfony\Component\Console\Command\Command;
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 UpdateDocs extends Base {
public function __construct(
private IConfig $config,
private IAppManager $appManager,
) {
parent::__construct();
}
public function isEnabled(): bool {
return $this->config->getSystemValue('debug', false) === true;
}
#[\Override]
protected function configure(): void {
$this
->setName('talk:developer:update-docs')
->setDescription('Update documentation of commands')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$info = $this->appManager->getAppInfo('spreed');
$documentation = "# Talk occ commands\n\n";
foreach ($info['commands'] as $namespace) {
if ($namespace === self::class
|| $namespace === AgeChatMessages::class) {
continue;
}
$command = $this->getCommand($namespace);
$documentation .= $this->getDocumentation($command) . "\n";
}
$handle = fopen(__DIR__ . '/../../../docs/occ.md', 'w');
fwrite($handle, $documentation);
fclose($handle);
return 0;
}
protected function getCommand(string $namespace): Command {
$command = Server::get($namespace);
// Clean full definition of command that have the default Symfony options
$command->setApplication($this->getApplication());
return $command;
}
protected function getDocumentation(Command $command): string {
$doc = '## ' . $command->getName() . "\n\n";
$doc .= $command->getDescription() . "\n\n";
$doc
.= '### Usage' . "\n\n"
. array_reduce(
array_merge(
[$command->getSynopsis()],
$command->getAliases(),
$command->getUsages()
),
function ($carry, $usage) {
return $carry . '* `' . $usage . '`' . "\n";
}
);
$doc .= $this->describeInputDefinition($command);
return $doc;
}
protected function describeInputDefinition(Command $command): string {
$definition = $command->getDefinition();
$text = '';
if (\count($definition->getArguments()) > 0) {
$text .= "\n";
$text .= "| Arguments | Description | Is required | Is array | Default |\n";
$text .= '|---|---|---|---|---|';
foreach ($definition->getArguments() as $argument) {
$describeInputArgument = $this->describeInputArgument($argument);
if ($describeInputArgument) {
$text .= "\n" . $describeInputArgument;
}
}
$text .= "\n";
}
if (\count($definition->getOptions()) > 0) {
$text .= "\n";
$text .= "| Options | Description | Accept value | Is value required | Is multiple | Default |\n";
$text .= '|---|---|---|---|---|---|';
foreach ($definition->getOptions() as $option) {
$describeInputOption = $this->describeInputOption($option);
if ($describeInputOption) {
$text .= "\n" . $describeInputOption;
}
}
$text .= "\n";
}
return $text;
}
protected function describeInputArgument(InputArgument $argument): string {
$description = $argument->getDescription();
return
'| `' . ($argument->getName() ?: '<none>') . '` | '
. ($description ? preg_replace('/\s*[\r\n]\s*/', ' ', $description) : '') . ' | '
. ($argument->isRequired() ? 'yes' : 'no') . ' | '
. ($argument->isArray() ? 'yes' : 'no') . ' | '
. ($argument->isRequired() ? '*Required*' : '`' . str_replace("\n", '', var_export($argument->getDefault(), true)) . '`') . ' |';
}
protected function describeInputOption(InputOption $option): string {
$name = '--' . $option->getName();
if ($option->getShortcut()) {
$name .= '\|-' . str_replace('|', '\|-', $option->getShortcut());
}
$description = $option->getDescription();
return
'| `' . $name . '` | '
. ($description ? preg_replace('/\s*[\r\n]\s*/', ' ', $description) : '') . ' | '
. ($option->acceptValue() ? 'yes' : 'no') . ' | '
. ($option->isValueRequired() ? 'yes' : 'no') . ' | '
. ($option->isArray() ? 'yes' : 'no') . ' | '
. ($option->isValueRequired() ? '*Required*' : '`' . str_replace("\n", '', var_export($option->getDefault(), true)) . '`') . ' |';
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Monitor;
use OC\Core\Command\Base;
use OCA\Talk\Participant;
use OCP\IDBConnection;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Calls extends Base {
public function __construct(
protected IDBConnection $connection,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
parent::configure();
$this
->setName('talk:monitor:calls')
->setDescription('Prints a list with conversations that have an active call as well as their participant count')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$query = $this->connection->getQueryBuilder();
$subQuery = $this->connection->getQueryBuilder();
$subQuery->select('attendee_id')
->from('talk_sessions')
->where($subQuery->expr()->gt('in_call', $query->createNamedParameter(Participant::FLAG_DISCONNECTED)))
->andWhere($subQuery->expr()->gt('last_ping', $query->createNamedParameter(time() - 60)))
->groupBy('attendee_id');
$query->select('r.token', $query->func()->count('*', 'num_attendees'))
->from('talk_attendees', 'a')
->leftJoin('a', 'talk_rooms', 'r', $query->expr()->eq('a.room_id', 'r.id'))
->where($query->expr()->in('a.id', $query->createFunction($subQuery->getSQL())))
->groupBy('r.token');
$data = [];
$result = $query->executeQuery();
while ($row = $result->fetch()) {
$key = (string)$row['token'];
if ($input->getOption('output') === Base::OUTPUT_FORMAT_PLAIN) {
$key = '"' . $key . '"';
}
$data[$key] = (int)$row['num_attendees'];
}
$result->closeCursor();
if ($input->getOption('output') === Base::OUTPUT_FORMAT_PLAIN) {
$numCalls = count($data);
$numParticipants = array_sum($data);
if (empty($data)) {
$output->writeln('<info>No calls in progress</info>');
} else {
$output->writeln(sprintf('<error>There are currently %1$d calls in progress with %2$d participants</error>', $numCalls, $numParticipants));
}
}
$this->writeArrayInOutputFormat($input, $output, $data);
return 0;
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Monitor;
use OC\Core\Command\Base;
use OCA\Talk\Participant;
use OCP\IDBConnection;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class HasActiveCalls extends Base {
public function __construct(
protected IDBConnection $connection,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
parent::configure();
$this
->setName('talk:active-calls')
->setDescription('Allows you to check if calls are currently in process')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$query = $this->connection->getQueryBuilder();
$query->select($query->func()->count('*', 'num_calls'))
->from('talk_rooms')
->where($query->expr()->isNotNull('active_since'));
$result = $query->executeQuery();
$numCalls = (int)$result->fetchColumn();
$result->closeCursor();
if ($numCalls === 0) {
if ($input->getOption('output') === 'plain') {
$output->writeln('<info>No calls in progress</info>');
} else {
$data = ['calls' => 0, 'participants' => 0];
$this->writeArrayInOutputFormat($input, $output, $data);
}
return 0;
}
$query = $this->connection->getQueryBuilder();
$query->select($query->func()->count('*', 'num_participants'))
->from('talk_sessions')
->where($query->expr()->gt('in_call', $query->createNamedParameter(Participant::FLAG_DISCONNECTED)))
->andWhere($query->expr()->gt('last_ping', $query->createNamedParameter(time() - 60)));
$result = $query->executeQuery();
$numParticipants = (int)$result->fetchColumn();
$result->closeCursor();
if ($input->getOption('output') === 'plain') {
$output->writeln(sprintf('<error>There are currently %1$d calls in progress with %2$d participants</error>', $numCalls, $numParticipants));
} else {
$data = ['calls' => $numCalls, 'participants' => $numParticipants];
$this->writeArrayInOutputFormat($input, $output, $data);
}
return 1;
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Monitor;
use OC\Core\Command\Base;
use OCA\Talk\Participant;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
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 Room extends Base {
public function __construct(
protected IDBConnection $connection,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
parent::configure();
$this
->setName('talk:monitor:room')
->setDescription('Prints the number of attendees, active sessions and participant in the call.')
->addArgument(
'token',
InputArgument::REQUIRED,
'Token of the room to monitor'
)
->addOption(
'separator',
null,
InputOption::VALUE_REQUIRED,
'Separator for the CSV list when output=csv is used',
','
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$token = $input->getArgument('token');
$query = $this->connection->getQueryBuilder();
$query->select('id')
->from('talk_rooms')
->where($query->expr()->eq('token', $query->createNamedParameter($token)));
$result = $query->executeQuery();
$roomId = (int)$result->fetchOne();
$result->closeCursor();
if ($roomId === 0) {
if ($input->getOption('output') === Base::OUTPUT_FORMAT_PLAIN) {
$output->writeln(sprintf('<error>Room with token %1$s not found</error>', $token));
}
return 1;
}
$query = $this->connection->getQueryBuilder();
$query->select($query->func()->count('*', 'num_attendees'))
->from('talk_attendees')
->where($query->expr()->eq('room_id', $query->createNamedParameter($roomId, IQueryBuilder::PARAM_INT)));
$result = $query->executeQuery();
$numAttendees = (int)$result->fetchOne();
$result->closeCursor();
$numSessions = $numSessionsInCall = 0;
$query = $this->connection->getQueryBuilder();
$query->select($query->func()->count('s.id', 'num_sessions'))
->from('talk_sessions', 's')
->leftJoin('s', 'talk_attendees', 'a', $query->expr()->eq('a.id', 's.attendee_id'))
->where($query->expr()->eq('a.room_id', $query->createNamedParameter($roomId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->gt('s.last_ping', $query->createNamedParameter(time() - 60, IQueryBuilder::PARAM_INT)));
$result = $query->executeQuery();
$numSessions = (int)$result->fetchOne();
$result->closeCursor();
$query = $this->connection->getQueryBuilder();
$query->select($query->func()->count('s.id', 'num_sessions'))
->from('talk_sessions', 's')
->leftJoin('s', 'talk_attendees', 'a', $query->expr()->eq('a.id', 's.attendee_id'))
->where($query->expr()->eq('a.room_id', $query->createNamedParameter($roomId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->gt('s.in_call', $query->createNamedParameter(Participant::FLAG_DISCONNECTED, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->gt('s.last_ping', $query->createNamedParameter(time() - 60, IQueryBuilder::PARAM_INT)));
$result = $query->executeQuery();
$numSessionsInCall = (int)$result->fetchOne();
$result->closeCursor();
if ($input->getOption('output') === Base::OUTPUT_FORMAT_PLAIN) {
$output->writeln(sprintf(
'The conversation has %1$d attendees with %2$d sessions of which %3$d are in the call.',
$numAttendees,
$numSessions,
$numSessionsInCall
));
return 0;
}
if ($input->getOption('output') === 'csv') {
$separator = $input->getOption('separator');
$output->writeln($numAttendees . $separator . $numSessions . $separator . $numSessionsInCall);
return 0;
}
$this->writeArrayInOutputFormat($input, $output, [
'attendees' => $numAttendees,
'sessions' => $numSessions,
'call' => $numSessionsInCall,
]);
return 0;
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\PhoneNumber;
use OC\Core\Command\Base;
use OCA\Talk\Model\PhoneNumber;
use OCA\Talk\Model\PhoneNumberMapper;
use OCA\Talk\Service\PhoneNumberValidation;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IUser;
use OCP\IUserManager;
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 AddPhoneNumber extends Base {
public function __construct(
private IUserManager $userManager,
private PhoneNumberValidation $phoneNumberValidation,
private PhoneNumberMapper $mapper,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('talk:phone-number:add')
->setDescription('Add a mapping entry to map a phone number to an user')
->addArgument(
'phone',
InputArgument::REQUIRED,
'Phone number that will be called',
)
->addArgument(
'user',
InputArgument::REQUIRED,
'User to be added to the conversation',
)
->addOption(
'force',
'f',
InputOption::VALUE_NONE,
'Force the number to the given user even when it is assigned already',
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$phoneNumber = $input->getArgument('phone');
$userId = $input->getArgument('user');
$force = (bool)$input->getOption('force');
$user = $this->userManager->get($userId);
if (!$user instanceof IUser) {
$output->writeln('<error>Invalid user "' . $userId . '" provided</error>');
return self::FAILURE;
}
$userId = $user->getUID();
try {
$phoneNumber = $this->phoneNumberValidation->validateNumber($phoneNumber);
} catch (\InvalidArgumentException) {
$output->writeln('<error>Not a valid phone number ' . $phoneNumber . '. The format is invalid.</error>');
return self::FAILURE;
}
try {
$entry = $this->mapper->findByPhoneNumber($phoneNumber);
} catch (DoesNotExistException) {
$entry = null;
}
if ($entry !== null) {
$oldActor = $entry->getActorId();
if (!$force) {
$output->writeln('<error>Phone number is already assigned to ' . $oldActor . '</error>');
return self::FAILURE;
}
$entry->setActorId($userId);
$this->mapper->update($entry);
$output->writeln('<info>Phone number ' . $entry->getPhoneNumber() . ' is now assigned to ' . $entry->getActorId() . '</info>');
$output->writeln('Was assigned to ' . $oldActor . ' before');
return self::SUCCESS;
}
$entry = new PhoneNumber();
$entry->setPhoneNumber($phoneNumber);
$entry->setActorId($userId);
$this->mapper->insert($entry);
$output->writeln('<info>Phone number ' . $entry->getPhoneNumber() . ' is now assigned to ' . $entry->getActorId() . '</info>');
return self::SUCCESS;
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\PhoneNumber;
use OC\Core\Command\Base;
use OCA\Talk\Model\PhoneNumberMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class FindPhoneNumber extends Base {
public function __construct(
private PhoneNumberMapper $mapper,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('talk:phone-number:find')
->setDescription('Find a phone number or the phone number of an user')
->addOption(
'phone',
null,
InputOption::VALUE_REQUIRED,
'Phone number to search for',
)
->addOption(
'user',
null,
InputOption::VALUE_REQUIRED,
'User to get number(s) for',
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$phoneNumber = (string)$input->getOption('phone');
$userId = (string)$input->getOption('user');
if ($phoneNumber !== '') {
try {
$entry = $this->mapper->findByPhoneNumber($phoneNumber);
} catch (DoesNotExistException) {
$output->writeln('<error>Phone number ' . $phoneNumber . ' could not be found</error>');
return self::FAILURE;
}
$output->writeln('Phone number ' . $entry->getPhoneNumber() . ' is assigned to ' . $entry->getActorId());
return self::SUCCESS;
}
if ($userId === '') {
$output->writeln('<error>Neither phone number nor user provided</error>');
return self::FAILURE;
}
$entries = $this->mapper->findByUser($userId);
if (empty($entries)) {
$output->writeln('<error>No phone number found for ' . $userId . '</error>');
return self::FAILURE;
}
if (count($entries) === 1) {
$entry = array_pop($entries);
$output->writeln($entry->getActorId() . ' has phone number ' . $entry->getPhoneNumber() . ' assigned');
} else {
$output->writeln($userId . ' has the following phone numbers assigned:');
foreach ($entries as $entry) {
$output->writeln(' - ' . $entry->getPhoneNumber());
}
}
return self::SUCCESS;
}
}
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\PhoneNumber;
use OC\Core\Command\Base;
use OCA\Talk\Model\PhoneNumber;
use OCA\Talk\Model\PhoneNumberMapper;
use OCA\Talk\Service\PhoneNumberValidation;
use OCP\IDBConnection;
use OCP\IUser;
use OCP\IUserManager;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class ImportPhoneNumbers extends Base {
public function __construct(
private IUserManager $userManager,
private PhoneNumberValidation $phoneNumberValidation,
private PhoneNumberMapper $mapper,
private IDBConnection $db,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('talk:phone-number:import')
->setDescription('Import a CSV list (format: "number","user") for SIP dial-in')
->addOption(
'reset',
null,
InputOption::VALUE_NONE,
'Delete all phone numbers before importing',
)
->addOption(
'force',
'f',
InputOption::VALUE_NONE,
'Force the numbers to the given user even when they are assigned already',
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$reset = (bool)$input->getOption('reset');
$force = (bool)$input->getOption('force');
$this->db->beginTransaction();
if ($reset) {
$this->db->truncateTable('talk_phone_numbers', false);
$force = false;
}
$handle = $this->getResourceFromStdin();
if ($handle === false) {
$output->writeln('<error>Invalid StdIn provided</error>');
return self::FAILURE;
}
$map = [];
while ($row = fgetcsv($handle, escape: '')) {
if (count($row) !== 2 || $row[0] === '' || $row[1] === '') {
continue;
}
try {
$row[0] = $this->phoneNumberValidation->validateNumber($row[0]);
} catch (\InvalidArgumentException) {
$output->writeln('<error>Not a valid phone number ' . $row[0] . '. The format is invalid.</error>');
return self::FAILURE;
}
$user = $this->userManager->get($row[1]);
if (!$user instanceof IUser) {
$output->writeln('<error>Invalid user "' . $row[1] . '" provided</error>');
return self::FAILURE;
}
$row[1] = $user->getUID();
$map[$row[0]] = $row[1];
}
$entries = $this->mapper->findByPhoneNumbers(array_keys($map));
if (!$force && !empty($entries)) {
$output->writeln('<error>Phone number already assigned:</error>');
foreach ($entries as $entry) {
$output->writeln(' - ' . $entry->getPhoneNumber());
}
return self::FAILURE;
}
foreach ($map as $phoneNumber => $userId) {
$entry = new PhoneNumber();
$entry->setPhoneNumber($phoneNumber);
$entry->setActorId($userId);
$this->mapper->insert($entry);
$output->writeln('<info>Phone number ' . $entry->getPhoneNumber() . ' is now assigned to ' . $entry->getActorId() . '</info>');
}
$this->db->commit();
return self::SUCCESS;
}
/**
* Get the resource from stdin ("talk:phone-numbers:import < file.csv")
* @return resource|false
*/
protected function getResourceFromStdin() {
return fopen('php://stdin', 'rb');
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\PhoneNumber;
use OC\Core\Command\Base;
use OCA\Talk\Model\PhoneNumberMapper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class RemovePhoneNumber extends Base {
public function __construct(
private PhoneNumberMapper $mapper,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('talk:phone-number:remove')
->setDescription('Remove a mapping entry by phone number')
->addArgument(
'phone',
InputArgument::REQUIRED,
'Phone number to remove the mapping entry for',
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$phoneNumber = $input->getArgument('phone');
$this->mapper->deleteByPhoneNumber($phoneNumber);
return self::SUCCESS;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\PhoneNumber;
use OC\Core\Command\Base;
use OCA\Talk\Model\PhoneNumberMapper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class RemoveUser extends Base {
public function __construct(
private PhoneNumberMapper $mapper,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('talk:phone-number:remove-user')
->setDescription('Remove mapping entries by user')
->addArgument(
'user',
InputArgument::REQUIRED,
'User to remove all mapping entries for',
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$userId = $input->getArgument('user');
$this->mapper->deleteByUser($userId);
return self::SUCCESS;
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Recording;
use OC\Core\Command\Base;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Manager;
use OCA\Talk\Room;
use OCA\Talk\Service\ConsentService;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class Consent extends Base {
public function __construct(
protected Manager $roomManager,
protected ConsentService $consentService,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
parent::configure();
$this
->setName('talk:recording:consent')
->setDescription('List all matching consent that were given to be audio and video recorded during a call (requires administrator or moderator configuration)')
->addOption(
'token',
null,
InputOption::VALUE_REQUIRED,
'Limit to the given conversation'
)
->addOption(
'actor-type',
null,
InputOption::VALUE_REQUIRED,
'Limit to the given actor (only valid when --actor-id is also provided)'
)
->addOption(
'actor-id',
null,
InputOption::VALUE_REQUIRED,
'Limit to the given actor (only valid when --actor-type is also provided)'
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$token = $input->getOption('token');
$actorType = $input->getOption('actor-type');
$actorId = $input->getOption('actor-id');
if (($actorType !== null) !== ($actorId !== null)) {
$output->writeln('<error>actor-type and actor-id must either both be specified or both left out</error>');
return 1;
}
$room = null;
if ($token !== null) {
try {
$room = $this->roomManager->getRoomByToken($token);
} catch (RoomNotFoundException) {
$output->writeln('<error>Conversation could not be found by token</error>');
return 2;
}
}
if ($actorType) {
if ($room instanceof Room) {
$consentData = $this->consentService->getConsentForRoomByActor($room, $actorType, $actorId);
} else {
$consentData = $this->consentService->getConsentForActor($actorType, $actorId);
}
} elseif ($room instanceof Room) {
$consentData = $this->consentService->getConsentForRoom($room);
} else {
$output->writeln('<error>No conversation or actor provided</error>');
return 3;
}
$this->writeTableInOutputFormat(
$input,
$output,
array_map(static fn (\OCA\Talk\Model\Consent $consent) => $consent->jsonSerialize(), $consentData)
);
return 0;
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Room;
use InvalidArgumentException;
use OC\Core\Command\Base;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Room;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
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 Add extends Base {
use TRoomCommand;
#[\Override]
protected function configure(): void {
$this
->setName('talk:room:add')
->setDescription('Adds users to a room')
->addArgument(
'token',
InputArgument::REQUIRED,
'Token of the room to add users to'
)->addOption(
'user',
null,
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Invites the given users to the room'
)->addOption(
'group',
null,
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Invites all members of the given groups to the room'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$token = $input->getArgument('token');
$users = $input->getOption('user');
$groups = $input->getOption('group');
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException $e) {
$output->writeln('<error>Room not found.</error>');
return 1;
}
if ($room->isFederatedConversation()) {
$output->writeln('<error>Room is a federated conversation.</error>');
return 1;
}
if (in_array($room->getType(), [Room::TYPE_ONE_TO_ONE, Room::TYPE_ONE_TO_ONE_FORMER], true)) {
$output->writeln('<error>Room is a private (1 to 1) conversation.</error>');
return 1;
}
try {
$this->addRoomParticipants($room, $users);
$this->addRoomParticipantsByGroup($room, $groups);
} catch (InvalidArgumentException $e) {
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
return 1;
}
$output->writeln('<info>Users successfully added to room.</info>');
return 0;
}
#[\Override]
public function completeOptionValues($optionName, CompletionContext $context) {
switch ($optionName) {
case 'user':
return $this->completeUserValues($context);
case 'group':
return $this->completeGroupValues($context);
}
return parent::completeOptionValues($optionName, $context);
}
#[\Override]
public function completeArgumentValues($argumentName, CompletionContext $context) {
switch ($argumentName) {
case 'token':
return $this->completeTokenValues($context);
}
return parent::completeArgumentValues($argumentName, $context);
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Room;
use InvalidArgumentException;
use OC\Core\Command\Base;
use OCA\Talk\Room;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
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 Create extends Base {
use TRoomCommand;
#[\Override]
protected function configure(): void {
$this
->setName('talk:room:create')
->setDescription('Create a new room')
->addArgument(
'name',
InputArgument::REQUIRED,
'The name of the room to create'
)->addOption(
'description',
null,
InputOption::VALUE_REQUIRED,
'The description of the room to create'
)->addOption(
'user',
null,
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Invites the given users to the room to create'
)->addOption(
'group',
null,
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Invites all members of the given group to the room to create'
)->addOption(
'public',
null,
InputOption::VALUE_NONE,
'Creates the room as public room if set'
)->addOption(
'readonly',
null,
InputOption::VALUE_NONE,
'Creates the room with read-only access only if set'
)->addOption(
'listable',
null,
InputOption::VALUE_REQUIRED,
'Creates the room with the given listable scope'
)->addOption(
'password',
null,
InputOption::VALUE_REQUIRED,
'Protects the room to create with the given password'
)->addOption(
'owner',
null,
InputOption::VALUE_REQUIRED,
'Sets the given user as owner of the room to create'
)->addOption(
'moderator',
null,
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Promotes the given users to moderators'
)->addOption(
'message-expiration',
null,
InputOption::VALUE_REQUIRED,
'Seconds to expire a message after sent. If zero will disable the expire message duration.'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$name = $input->getArgument('name');
$description = $input->getOption('description');
$users = $input->getOption('user');
$groups = $input->getOption('group');
$public = $input->getOption('public');
$readonly = $input->getOption('readonly');
$listable = $input->getOption('listable');
$password = $input->getOption('password');
$owner = $input->getOption('owner');
$moderators = $input->getOption('moderator');
$messageExpiration = $input->getOption('message-expiration');
if (!in_array($listable, [
null,
(string)Room::LISTABLE_NONE,
(string)Room::LISTABLE_USERS,
(string)Room::LISTABLE_ALL,
], true)) {
$output->writeln('<error>Invalid value for option "--listable" given.</error>');
return 1;
}
$roomType = $public ? Room::TYPE_PUBLIC : Room::TYPE_GROUP;
try {
$room = $this->roomService->createConversation($roomType, $name);
} catch (InvalidArgumentException $e) {
if ($e->getMessage() === 'name') {
$output->writeln('<error>Invalid room name.</error>');
return 1;
}
throw $e;
}
try {
if ($description !== null) {
$this->setRoomDescription($room, $description);
}
$this->setRoomReadOnly($room, $readonly);
$this->setRoomListable($room, (int)$listable);
if ($password !== null) {
$this->setRoomPassword($room, $password);
}
$this->addRoomParticipants($room, $users);
$this->addRoomParticipantsByGroup($room, $groups);
$this->addRoomModerators($room, $moderators);
if ($owner !== null) {
$this->setRoomOwner($room, $owner);
}
if ($messageExpiration !== null) {
$this->setMessageExpiration($room, (int)$messageExpiration);
}
} catch (InvalidArgumentException $e) {
$this->roomService->deleteRoom($room);
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
return 1;
}
$output->writeln('Room token: ' . $room->getToken());
$output->writeln('<info>Room successfully created.</info>');
return 0;
}
#[\Override]
public function completeOptionValues($optionName, CompletionContext $context) {
switch ($optionName) {
case 'user':
return $this->completeUserValues($context);
case 'group':
return $this->completeGroupValues($context);
case 'owner':
case 'moderator':
return $this->completeParticipantValues($context);
case 'readonly':
return [(string)Room::READ_ONLY, (string)Room::READ_WRITE];
case 'listable':
return [
(string)Room::LISTABLE_ALL,
(string)Room::LISTABLE_USERS,
(string)Room::LISTABLE_NONE,
];
}
return parent::completeOptionValues($optionName, $context);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Room;
use OC\Core\Command\Base;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Room;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Delete extends Base {
use TRoomCommand;
#[\Override]
protected function configure(): void {
$this
->setName('talk:room:delete')
->setDescription('Deletes a room')
->addArgument(
'token',
InputArgument::REQUIRED,
'Token of the room to delete'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$token = $input->getArgument('token');
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException $e) {
$output->writeln('<error>Room not found.</error>');
return 1;
}
if ($room->isFederatedConversation()) {
$output->writeln('<error>Room is a federated conversation.</error>');
return 1;
}
if (in_array($room->getType(), [Room::TYPE_ONE_TO_ONE], true)) {
$output->writeln('<error>Room is a private (1 to 1) conversation.</error>');
return 1;
}
$this->roomService->deleteRoom($room);
$output->writeln('<info>Room successfully deleted.</info>');
return 0;
}
#[\Override]
public function completeArgumentValues($argumentName, CompletionContext $context) {
switch ($argumentName) {
case 'token':
return $this->completeTokenValues($context);
}
return parent::completeArgumentValues($argumentName, $context);
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Room;
use InvalidArgumentException;
use OC\Core\Command\Base;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Room;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Demote extends Base {
use TRoomCommand;
#[\Override]
protected function configure(): void {
$this
->setName('talk:room:demote')
->setDescription('Demotes participants of a room to regular users')
->addArgument(
'token',
InputArgument::REQUIRED,
'Token of the room in which users should be demoted'
)->addArgument(
'participant',
InputArgument::REQUIRED | InputArgument::IS_ARRAY,
'Demotes the given participants of the room to regular users'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$token = $input->getArgument('token');
$users = $input->getArgument('participant');
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException $e) {
$output->writeln('<error>Room not found.</error>');
return 1;
}
if ($room->isFederatedConversation()) {
$output->writeln('<error>Room is a federated conversation.</error>');
return 1;
}
if (in_array($room->getType(), [Room::TYPE_ONE_TO_ONE, Room::TYPE_ONE_TO_ONE_FORMER], true)) {
$output->writeln('<error>Room is a private (1 to 1) conversation.</error>');
return 1;
}
try {
$this->removeRoomModerators($room, $users);
} catch (InvalidArgumentException $e) {
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
return 1;
}
$output->writeln('<info>Participants successfully demoted to regular users.</info>');
return 0;
}
#[\Override]
public function completeArgumentValues($argumentName, CompletionContext $context) {
switch ($argumentName) {
case 'token':
return $this->completeTokenValues($context);
case 'participant':
return $this->completeParticipantValues($context);
}
return parent::completeArgumentValues($argumentName, $context);
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Room;
use InvalidArgumentException;
use OC\Core\Command\Base;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Room;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Promote extends Base {
use TRoomCommand;
#[\Override]
protected function configure(): void {
$this
->setName('talk:room:promote')
->setDescription('Promotes participants of a room to moderators')
->addArgument(
'token',
InputArgument::REQUIRED,
'Token of the room in which users should be promoted'
)->addArgument(
'participant',
InputArgument::REQUIRED | InputArgument::IS_ARRAY,
'Promotes the given participants of the room to moderators'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$token = $input->getArgument('token');
$users = $input->getArgument('participant');
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException $e) {
$output->writeln('<error>Room not found.</error>');
return 1;
}
if ($room->isFederatedConversation()) {
$output->writeln('<error>Room is a federated conversation.</error>');
return 1;
}
if (in_array($room->getType(), [Room::TYPE_ONE_TO_ONE, Room::TYPE_ONE_TO_ONE_FORMER], true)) {
$output->writeln('<error>Room is a private (1 to 1) conversation.</error>');
return 1;
}
try {
$this->addRoomModerators($room, $users);
} catch (InvalidArgumentException $e) {
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
return 1;
}
$output->writeln('<info>Participants successfully promoted to moderators.</info>');
return 0;
}
#[\Override]
public function completeArgumentValues($argumentName, CompletionContext $context) {
switch ($argumentName) {
case 'token':
return $this->completeTokenValues($context);
case 'participant':
return $this->completeParticipantValues($context);
}
return parent::completeArgumentValues($argumentName, $context);
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Room;
use InvalidArgumentException;
use OC\Core\Command\Base;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Room;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Remove extends Base {
use TRoomCommand;
#[\Override]
protected function configure(): void {
$this
->setName('talk:room:remove')
->setDescription('Remove users from a room')
->addArgument(
'token',
InputArgument::REQUIRED,
'Token of the room to remove users from'
)->addArgument(
'participant',
InputArgument::REQUIRED | InputArgument::IS_ARRAY,
'Removes the given participants from the room'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$token = $input->getArgument('token');
$users = $input->getArgument('participant');
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException $e) {
$output->writeln('<error>Room not found.</error>');
return 1;
}
if ($room->isFederatedConversation()) {
$output->writeln('<error>Room is a federated conversation.</error>');
return 1;
}
if (in_array($room->getType(), [Room::TYPE_ONE_TO_ONE, Room::TYPE_ONE_TO_ONE_FORMER], true)) {
$output->writeln('<error>Room is a private (1 to 1) conversation.</error>');
return 1;
}
try {
$this->removeRoomParticipants($room, $users);
} catch (InvalidArgumentException $e) {
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
return 1;
}
$output->writeln('<info>Users successfully removed from room.</info>');
return 0;
}
#[\Override]
public function completeArgumentValues($argumentName, CompletionContext $context) {
switch ($argumentName) {
case 'token':
return $this->completeTokenValues($context);
case 'participant':
return $this->completeParticipantValues($context);
}
return parent::completeArgumentValues($argumentName, $context);
}
}
+417
View File
@@ -0,0 +1,417 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Room;
use InvalidArgumentException;
use OCA\Talk\Events\AAttendeeRemovedEvent;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Exceptions\RoomProperty\DescriptionException;
use OCA\Talk\Exceptions\RoomProperty\ListableException;
use OCA\Talk\Exceptions\RoomProperty\MessageExpirationException;
use OCA\Talk\Exceptions\RoomProperty\NameException;
use OCA\Talk\Exceptions\RoomProperty\PasswordException;
use OCA\Talk\Exceptions\RoomProperty\ReadOnlyException;
use OCA\Talk\Exceptions\RoomProperty\TypeException;
use OCA\Talk\Manager;
use OCA\Talk\MatterbridgeManager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\RoomService;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IUser;
use OCP\IUserManager;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputDefinition;
trait TRoomCommand {
public function __construct(
protected Manager $manager,
protected RoomService $roomService,
protected ParticipantService $participantService,
protected IUserManager $userManager,
protected IGroupManager $groupManager,
) {
parent::__construct();
}
/**
* @param Room $room
* @param string $name
*
* @throws InvalidArgumentException
*/
protected function setRoomName(Room $room, string $name): void {
$name = trim($name);
if ($name === $room->getName()) {
return;
}
if (!$this->validateRoomName($name)) {
throw new InvalidArgumentException('Invalid room name.');
}
try {
$this->roomService->setName($room, $name);
} catch (NameException) {
throw new InvalidArgumentException('Unable to change room name.');
}
}
/**
* @param string $name
*
* @return bool
*
* @throws InvalidArgumentException
*/
protected function validateRoomName(string $name): bool {
$name = trim($name);
return (($name !== '') && !isset($name[255]));
}
/**
* @param Room $room
* @param string $description
*
* @throws InvalidArgumentException
*/
protected function setRoomDescription(Room $room, string $description): void {
try {
$this->roomService->setDescription($room, $description);
} catch (DescriptionException $e) {
throw new InvalidArgumentException('Invalid room description.');
}
}
/**
* @param Room $room
* @param bool $public
*
* @throws InvalidArgumentException
*/
protected function setRoomPublic(Room $room, bool $public): void {
if ($public === ($room->getType() === Room::TYPE_PUBLIC)) {
return;
}
try {
$this->roomService->setType($room, $public ? Room::TYPE_PUBLIC : Room::TYPE_GROUP);
} catch (TypeException) {
throw new InvalidArgumentException('Unable to change room type.');
}
}
/**
* @param Room $room
* @param bool $readOnly
*
* @throws InvalidArgumentException
*/
protected function setRoomReadOnly(Room $room, bool $readOnly): void {
if ($readOnly === ($room->getReadOnly() === Room::READ_ONLY)) {
return;
}
try {
$this->roomService->setReadOnly($room, $readOnly ? Room::READ_ONLY : Room::READ_WRITE);
} catch (ReadOnlyException) {
throw new InvalidArgumentException('Unable to change room state.');
}
}
/**
* @param Room $room
* @param int $listable
*
* @throws InvalidArgumentException
*/
protected function setRoomListable(Room $room, int $listable): void {
if ($room->getListable() === $listable) {
return;
}
try {
$this->roomService->setListable($room, $listable);
} catch (ListableException) {
throw new InvalidArgumentException('Unable to change room state.');
}
}
/**
* @param Room $room
* @param string $password
*
* @throws InvalidArgumentException
*/
protected function setRoomPassword(Room $room, string $password): void {
if ($room->hasPassword() ? $this->roomService->verifyPassword($room, $password)['result'] : ($password === '')) {
return;
}
if (($password !== '') && ($room->getType() !== Room::TYPE_PUBLIC)) {
throw new InvalidArgumentException('Unable to add password protection to private room.');
}
try {
$this->roomService->setPassword($room, $password);
} catch (PasswordException $e) {
if ($e->getReason() === PasswordException::REASON_VALUE) {
throw new InvalidArgumentException($e->getHint());
}
throw new InvalidArgumentException('Unable to change room password.');
}
}
/**
* @param Room $room
* @param string $userId
*
* @throws InvalidArgumentException
*/
protected function setRoomOwner(Room $room, string $userId): void {
try {
$participant = $this->participantService->getParticipant($room, $userId, false);
} catch (ParticipantNotFoundException $e) {
throw new InvalidArgumentException(sprintf("User '%s' is no participant.", $userId));
}
if ($userId === MatterbridgeManager::BRIDGE_BOT_USERID) {
throw new InvalidArgumentException('Can not promote the bridge-bot user.');
}
$this->unsetRoomOwner($room);
$this->participantService->updateParticipantType($room, $participant, Participant::OWNER);
}
/**
* @param Room $room
*
* @throws InvalidArgumentException
*/
protected function unsetRoomOwner(Room $room): void {
$participants = $this->participantService->getParticipantsForRoom($room);
foreach ($participants as $participant) {
if ($participant->getAttendee()->getParticipantType() === Participant::OWNER) {
$this->participantService->updateParticipantType($room, $participant, Participant::USER);
}
}
}
/**
* @param Room $room
* @param string[] $groupIds
*
* @throws InvalidArgumentException
*/
protected function addRoomParticipantsByGroup(Room $room, array $groupIds): void {
if (!$groupIds) {
return;
}
foreach ($groupIds as $groupId) {
$group = $this->groupManager->get($groupId);
if ($group === null) {
throw new InvalidArgumentException(sprintf("Group '%s' not found.", $groupId));
}
$this->participantService->addGroup($room, $group);
}
}
/**
* @param Room $room
* @param string[] $userIds
*
* @throws InvalidArgumentException
*/
protected function addRoomParticipants(Room $room, array $userIds): void {
if (!$userIds) {
return;
}
/** @var array<string, array{actorType: string, actorId: string, displayName: string}> $participants */
$participants = [];
foreach ($userIds as $userId) {
if ($userId === MatterbridgeManager::BRIDGE_BOT_USERID) {
throw new InvalidArgumentException('Can not add the bridge-bot user.');
}
$user = $this->userManager->get($userId);
if ($user === null) {
throw new InvalidArgumentException(sprintf("User '%s' not found.", $userId));
}
if (isset($participants[$user->getUID()])) {
// nothing to do, user is going to be a participant already
continue;
}
try {
$this->participantService->getParticipant($room, $user->getUID(), false);
// nothing to do, user is a participant already
continue;
} catch (ParticipantNotFoundException $e) {
// we expect the user not to be a participant yet
}
$participants[$user->getUID()] = [
'actorType' => Attendee::ACTOR_USERS,
'actorId' => $user->getUID(),
'displayName' => $user->getDisplayName(),
];
}
$this->participantService->addUsers($room, $participants);
}
/**
* @param Room $room
* @param string[] $userIds
*
* @throws InvalidArgumentException
*/
protected function removeRoomParticipants(Room $room, array $userIds): void {
$users = [];
foreach ($userIds as $userId) {
try {
$this->participantService->getParticipant($room, $userId, false);
} catch (ParticipantNotFoundException $e) {
throw new InvalidArgumentException(sprintf("User '%s' is no participant.", $userId));
}
$users[] = $this->userManager->get($userId);
}
foreach ($users as $user) {
$this->participantService->removeUser($room, $user, AAttendeeRemovedEvent::REASON_REMOVED);
}
}
/**
* @param Room $room
* @param string[] $userIds
*
* @throws InvalidArgumentException
*/
protected function addRoomModerators(Room $room, array $userIds): void {
$participants = [];
foreach ($userIds as $userId) {
if ($userId === MatterbridgeManager::BRIDGE_BOT_USERID) {
throw new InvalidArgumentException('Can not promote the bridge-bot user.');
}
try {
$participant = $this->participantService->getParticipant($room, $userId, false);
} catch (ParticipantNotFoundException $e) {
throw new InvalidArgumentException(sprintf("User '%s' is no participant.", $userId));
}
if ($participant->getAttendee()->getParticipantType() !== Participant::OWNER) {
$participants[] = $participant;
}
}
foreach ($participants as $participant) {
$this->participantService->updateParticipantType($room, $participant, Participant::MODERATOR);
}
}
/**
* @param Room $room
* @param string[] $userIds
*
* @throws InvalidArgumentException
*/
protected function removeRoomModerators(Room $room, array $userIds): void {
$participants = [];
foreach ($userIds as $userId) {
try {
$participant = $this->participantService->getParticipant($room, $userId, false);
} catch (ParticipantNotFoundException $e) {
throw new InvalidArgumentException(sprintf("User '%s' is no participant.", $userId));
}
if ($participant->getAttendee()->getParticipantType() === Participant::MODERATOR) {
$participants[] = $participant;
}
}
foreach ($participants as $participant) {
$this->participantService->updateParticipantType($room, $participant, Participant::USER);
}
}
protected function completeTokenValues(CompletionContext $context): array {
return array_map(function (Room $room) {
return $room->getToken();
}, $this->manager->searchRoomsByToken($context->getCurrentWord()));
}
protected function completeUserValues(CompletionContext $context): array {
return array_map(function (IUser $user) {
if ($user->getUID() === MatterbridgeManager::BRIDGE_BOT_USERID) {
return '';
}
return $user->getUID();
}, $this->userManager->search($context->getCurrentWord()));
}
protected function completeGroupValues(CompletionContext $context): array {
return array_map(function (IGroup $group) {
return $group->getGID();
}, $this->groupManager->search($context->getCurrentWord()));
}
protected function completeParticipantValues(CompletionContext $context): array {
$definition = new InputDefinition();
if ($this->getApplication() !== null) {
$definition->addArguments($this->getApplication()->getDefinition()->getArguments());
$definition->addOptions($this->getApplication()->getDefinition()->getOptions());
}
$definition->addArguments($this->getDefinition()->getArguments());
$definition->addOptions($this->getDefinition()->getOptions());
$input = new ArgvInput($context->getWords(), $definition);
if ($input->hasArgument('token')) {
$token = $input->getArgument('token');
} elseif ($input->hasOption('token')) {
$token = $input->getOption('token');
} else {
return [];
}
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException $e) {
return [];
}
return array_filter($this->participantService->getParticipantUserIds($room), static function ($userId) use ($context) {
return stripos($userId, $context->getCurrentWord()) !== false;
});
}
protected function setMessageExpiration(Room $room, int $seconds): void {
try {
$this->roomService->setMessageExpiration($room, $seconds);
} catch (MessageExpirationException) {
throw new InvalidArgumentException('Unable to change message expiration.');
}
}
}
+198
View File
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Room;
use InvalidArgumentException;
use OC\Core\Command\Base;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Room;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
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 Update extends Base {
use TRoomCommand;
#[\Override]
protected function configure(): void {
$this
->setName('talk:room:update')
->setDescription('Updates a room')
->addArgument(
'token',
InputArgument::REQUIRED,
'The token of the room to update'
)->addOption(
'name',
null,
InputOption::VALUE_REQUIRED,
'Sets a new name for the room'
)->addOption(
'description',
null,
InputOption::VALUE_REQUIRED,
'Sets a new description for the room'
)->addOption(
'public',
null,
InputOption::VALUE_REQUIRED,
'Modifies the room to be a public room (value 1) or private room (value 0)'
)->addOption(
'readonly',
null,
InputOption::VALUE_REQUIRED,
'Modifies the room to be read-only (value 1) or read-write (value 0)'
)->addOption(
'listable',
null,
InputOption::VALUE_REQUIRED,
'Modifies the room\'s listable scope'
)->addOption(
'password',
null,
InputOption::VALUE_REQUIRED,
'Sets a new password for the room; pass an empty value to remove password protection'
)->addOption(
'owner',
null,
InputOption::VALUE_REQUIRED,
'Sets the given user as owner of the room; pass an empty value to remove the owner'
)->addOption(
'message-expiration',
null,
InputOption::VALUE_REQUIRED,
'Seconds to expire a message after sent. If zero will disable the expire message duration.'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$token = $input->getArgument('token');
$name = $input->getOption('name');
$description = $input->getOption('description');
$public = $input->getOption('public');
$readOnly = $input->getOption('readonly');
$listable = $input->getOption('listable');
$password = $input->getOption('password');
$owner = $input->getOption('owner');
$messageExpiration = $input->getOption('message-expiration');
if (!in_array($public, [null, '0', '1'], true)) {
$output->writeln('<error>Invalid value for option "--public" given.</error>');
return 1;
}
if (!in_array($readOnly, [null, (string)Room::READ_WRITE, (string)Room::READ_ONLY], true)) {
$output->writeln('<error>Invalid value for option "--readonly" given.</error>');
return 1;
}
if (!in_array($listable, [
null,
(string)Room::LISTABLE_NONE,
(string)Room::LISTABLE_USERS,
(string)Room::LISTABLE_ALL,
], true)) {
$output->writeln('<error>Invalid value for option "--listable" given.</error>');
return 1;
}
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException $e) {
$output->writeln('<error>Room not found.</error>');
return 1;
}
if ($room->isFederatedConversation()) {
$output->writeln('<error>Room is a federated conversation.</error>');
return 1;
}
if (in_array($room->getType(), [Room::TYPE_ONE_TO_ONE, Room::TYPE_ONE_TO_ONE_FORMER], true)) {
$output->writeln('<error>Room is a private (1 to 1) conversation.</error>');
return 1;
}
try {
if ($name !== null) {
$this->setRoomName($room, $name);
}
if ($description !== null) {
$this->setRoomDescription($room, $description);
}
if ($public !== null) {
$this->setRoomPublic($room, ($public === '1'));
}
if ($readOnly !== null) {
$this->setRoomReadOnly($room, ($readOnly === '1'));
}
if ($listable !== null) {
$this->setRoomListable($room, (int)$listable);
}
if ($password !== null) {
$this->setRoomPassword($room, $password);
}
if ($owner !== null) {
if ($owner !== '') {
$this->setRoomOwner($room, $owner);
} else {
$this->unsetRoomOwner($room);
}
}
if ($messageExpiration !== null) {
$this->setMessageExpiration($room, (int)$messageExpiration);
}
} catch (InvalidArgumentException $e) {
$output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
return 1;
}
$output->writeln('<info>Room successfully updated.</info>');
return 0;
}
#[\Override]
public function completeOptionValues($optionName, CompletionContext $context) {
switch ($optionName) {
case 'public':
case 'readonly':
return [(string)Room::READ_ONLY, (string)Room::READ_WRITE];
case 'listable':
return [
(string)Room::LISTABLE_ALL,
(string)Room::LISTABLE_USERS,
(string)Room::LISTABLE_NONE,
];
case 'owner':
return $this->completeParticipantValues($context);
}
return parent::completeOptionValues($optionName, $context);
}
#[\Override]
public function completeArgumentValues($argumentName, CompletionContext $context) {
switch ($argumentName) {
case 'token':
return $this->completeTokenValues($context);
}
return parent::completeArgumentValues($argumentName, $context);
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Signaling;
use OC\Core\Command\Base;
use OCP\IConfig;
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 Add extends Base {
public function __construct(
private IConfig $config,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('talk:signaling:add')
->setDescription('Add an external signaling server.')
->addArgument(
'server',
InputArgument::REQUIRED,
'A server string, ex. wss://signaling.example.org'
)->addArgument(
'secret',
InputArgument::REQUIRED,
'A shared secret string.'
)->addOption(
'verify',
null,
InputOption::VALUE_NONE,
'Validate SSL certificate if set.'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$server = $input->getArgument('server');
$secret = $input->getArgument('secret');
$verify = $input->getOption('verify');
// quick validation, similar to signaling-server.js
if (trim($server) === '') {
$output->writeln('<error>Server cannot be empty.</error>');
return 1;
}
if (trim($secret) === '') {
$output->writeln('<error>Secret cannot be empty.</error>');
return 1;
}
$config = $this->config->getAppValue('spreed', 'signaling_servers');
$signaling = json_decode($config, true);
if ($signaling === null || empty($signaling) || !is_array($signaling)) {
$servers = [];
} else {
$servers = is_array($signaling['servers']) ? $signaling['servers'] : [];
}
$servers[] = [
'server' => $server,
'verify' => $verify,
];
$signaling = [
'servers' => $servers,
'secret' => $secret,
];
$this->config->setAppValue('spreed', 'signaling_servers', json_encode($signaling));
$output->writeln('<info>Added signaling server ' . $server . '.</info>');
return 0;
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Signaling;
use OC\Core\Command\Base;
use OCP\IConfig;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Delete extends Base {
public function __construct(
private IConfig $config,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('talk:signaling:delete')
->setDescription('Remove an existing signaling server.')
->addArgument(
'server',
InputArgument::REQUIRED,
'An external signaling server string, ex. wss://signaling.example.org'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$server = $input->getArgument('server');
$config = $this->config->getAppValue('spreed', 'signaling_servers');
$signaling = json_decode($config, true);
if ($signaling === null || empty($signaling) || !is_array($signaling)) {
$signaling = [
'servers' => [],
'secret' => '',
];
}
$count = count($signaling['servers']);
// remove all occurrences of $server
$servers = array_filter($signaling['servers'], function ($s) use ($server) {
return $s['server'] !== $server;
});
$signaling['servers'] = array_values($servers); // reindex
$this->config->setAppValue('spreed', 'signaling_servers', json_encode($signaling));
if ($count > count($signaling['servers'])) {
$output->writeln('<info>Deleted ' . $server . '.</info>');
} else {
$output->writeln('<info>There is nothing to delete.</info>');
}
return 0;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Signaling;
use OC\Core\Command\Base;
use OCP\IConfig;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ListCommand extends Base {
public function __construct(
private IConfig $config,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
parent::configure();
$this
->setName('talk:signaling:list')
->setDescription('List external signaling servers.');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$config = $this->config->getAppValue('spreed', 'signaling_servers');
$signaling = json_decode($config, true);
if (!is_array($signaling)) {
$signaling = [];
}
$this->writeMixedInOutputFormat($input, $output, $signaling);
return 0;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Signaling;
use OC\Core\Command\Base;
use OCA\Talk\Config;
use OCP\IConfig;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class VerifyKeys extends Base {
public function __construct(
private IConfig $config,
private Config $talkConfig,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
parent::configure();
$this
->setName('talk:signaling:verify-keys')
->setDescription('Verify if the stored public key matches the stored private key for the signaling server')
->addOption('update', null, InputOption::VALUE_NONE, 'Updates the stored public key to match the private key if there is a mis-match');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$update = $input->getOption('update');
$alg = $this->talkConfig->getSignalingTokenAlgorithm();
$privateKey = $this->talkConfig->getSignalingTokenPrivateKey();
$publicKey = $this->talkConfig->getSignalingTokenPublicKey();
$publicKeyDerived = $this->talkConfig->deriveSignalingTokenPublicKey($privateKey, $alg);
$output->writeln('Stored public key:');
$output->writeln($publicKey);
$output->writeln('Derived public key:');
$output->writeln($publicKeyDerived);
if ($publicKey != $publicKeyDerived) {
if ($update) {
$output->writeln('<comment>Stored public key for algorithm ' . strtolower($alg) . ' did not match stored private key.</comment>');
$output->writeln('<info>A new public key was created and stored.</info>');
$this->config->setAppValue('spreed', 'signaling_token_pubkey_' . strtolower($alg), $publicKeyDerived);
return 0;
}
$output->writeln('<error>Stored public key for algorithm ' . strtolower($alg) . ' does not match stored private key</error>');
return 1;
}
$output->writeln('<info>Stored public key for algorithm ' . strtolower($alg) . ' matches stored private key</info>');
return 0;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Stun;
use OC\Core\Command\Base;
use OCP\IConfig;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Add extends Base {
public function __construct(
private IConfig $config,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('talk:stun:add')
->setDescription('Add a new STUN server.')
->addArgument(
'server',
InputArgument::REQUIRED,
'A domain name and port number separated by the colons, ex. stun.nextcloud.com:443'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$server = $input->getArgument('server');
// check input, similar to stun-server.js
$host = parse_url($server, PHP_URL_HOST);
$port = parse_url($server, PHP_URL_PORT);
if (empty($host) || empty($port)) {
$output->writeln('<error>Incorrect value. Must be stunserver:port.</error>');
return 1;
}
$config = $this->config->getAppValue('spreed', 'stun_servers');
$servers = json_decode($config, true);
if ($servers === null || empty($servers) || !is_array($servers)) {
$servers = [];
}
// check if the server is already in the list
foreach ($servers as $existingServer) {
if ($existingServer === "$host:$port") {
$output->writeln('<error>Server already exists.</error>');
return 1;
}
}
$servers[] = "$host:$port";
$this->config->setAppValue('spreed', 'stun_servers', json_encode($servers));
$output->writeln('<info>Added ' . "$host:$port" . '.</info>');
return 0;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Stun;
use OC\Core\Command\Base;
use OCP\IConfig;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Delete extends Base {
public function __construct(
private IConfig $config,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('talk:stun:delete')
->setDescription('Remove an existing STUN server.')
->addArgument(
'server',
InputArgument::REQUIRED,
'A domain name and port number separated by the colons, ex. stun.nextcloud.com:443'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$server = $input->getArgument('server');
$config = $this->config->getAppValue('spreed', 'stun_servers');
$servers = json_decode($config);
if (! is_array($servers)) {
$servers = [];
}
$count = count($servers);
// remove all occurrences of $server
$servers = array_filter($servers, function ($s) use ($server) {
return $s !== $server;
});
$servers = array_values($servers); // reindex
if (empty($servers)) {
$servers = ['stun.nextcloud.com:443'];
$this->config->setAppValue('spreed', 'stun_servers', json_encode($servers));
$output->writeln('<info>You deleted all STUN servers. A default STUN server was added.</info>');
} else {
$this->config->setAppValue('spreed', 'stun_servers', json_encode($servers));
if ($count > count($servers)) {
$output->writeln('<info>Deleted ' . $server . '.</info>');
} else {
$output->writeln('<info>There is nothing to delete.</info>');
}
}
return 0;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Stun;
use OC\Core\Command\Base;
use OCP\IConfig;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ListCommand extends Base {
public function __construct(
private IConfig $config,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
parent::configure();
$this
->setName('talk:stun:list')
->setDescription('List STUN servers.');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$config = $this->config->getAppValue('spreed', 'stun_servers');
$servers = json_decode($config);
if (!is_array($servers)) {
$servers = [];
}
$this->writeArrayInOutputFormat($input, $output, $servers);
return 0;
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Turn;
use OC\Core\Command\Base;
use OCP\IConfig;
use OCP\Security\ISecureRandom;
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 Add extends Base {
public function __construct(
private IConfig $config,
private ISecureRandom $secureRandom,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('talk:turn:add')
->setDescription('Add a TURN server.')
->addArgument(
'schemes',
InputArgument::REQUIRED,
'Schemes, can be turn or turns or turn,turns.'
)->addArgument(
'server',
InputArgument::REQUIRED,
'A domain name, ex. turn.nextcloud.com'
)->addArgument(
'protocols',
InputArgument::REQUIRED,
'Protocols, can be udp or tcp or udp,tcp.'
)->addOption(
'secret',
null,
InputOption::VALUE_REQUIRED,
'A shard secret string'
)->addOption(
'generate-secret',
null,
InputOption::VALUE_NONE,
'Generate secret if set.'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$schemes = $input->getArgument('schemes');
$server = $input->getArgument('server');
$protocols = $input->getArgument('protocols');
$secret = $input->getOption('secret');
$generate = $input->getOption('generate-secret');
if (!in_array($schemes, ['turn', 'turns', 'turn,turns'])) {
$output->writeln('<error>Not allowed schemes, must be turn or turns or turn,turns.</error>');
return 1;
}
if (!in_array($protocols, ['tcp', 'udp', 'udp,tcp'])) {
$output->writeln('<error>Not allowed protocols, must be udp or tcp or udp,tcp.</error>');
return 1;
}
// quick validation, similar to turn-server.js
if (trim($server) === '') {
$output->writeln('<error>Server cannot be empty.</error>');
return 1;
}
if (($generate === false && $secret === null)
|| ($generate && $secret !== null)) {
$output->writeln('<error>You must provide --secret or --generate-secret.</error>');
return 1;
}
if (!$generate && trim($secret) === '') {
$output->writeln('<error>Secret cannot be empty.</error>');
return 1;
}
if ($generate) {
$secret = $this->secureRandom->generate(128);
}
if (stripos($server, 'https://') === 0) {
$server = substr($server, 8);
}
if (stripos($server, 'http://') === 0) {
$server = substr($server, 7);
}
$config = $this->config->getAppValue('spreed', 'turn_servers');
$servers = json_decode($config, true);
if ($servers === null || empty($servers) || !is_array($servers)) {
$servers = [];
}
//Checking if the server is already added
foreach ($servers as $existingServer) {
if (
$existingServer['schemes'] === $schemes
&& $existingServer['server'] === $server
&& $existingServer['protocols'] === $protocols
) {
$output->writeln('<error>Server already exists with the same configuration.</error>');
return 1;
}
}
$servers[] = [
'schemes' => $schemes,
'server' => $server,
'secret' => $secret, // @todo: check the order
'protocols' => $protocols,
];
$this->config->setAppValue('spreed', 'turn_servers', json_encode($servers));
$output->writeln('<info>Added ' . $server . '.</info>');
return 0;
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Turn;
use OC\Core\Command\Base;
use OCP\IConfig;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Delete extends Base {
public function __construct(
private IConfig $config,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('talk:turn:delete')
->setDescription('Remove an existing TURN server.')
->addArgument(
'schemes',
InputArgument::REQUIRED,
'Schemes, can be turn or turns or turn,turns'
)->addArgument(
'server',
InputArgument::REQUIRED,
'A domain name, ex. turn.nextcloud.com'
)->addArgument(
'protocols',
InputArgument::REQUIRED,
'Protocols, can be udp or tcp or udp,tcp'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$schemes = $input->getArgument('schemes');
$server = $input->getArgument('server');
$protocols = $input->getArgument('protocols');
$config = $this->config->getAppValue('spreed', 'turn_servers');
$servers = json_decode($config, true);
if ($servers === null || empty($servers) || !is_array($servers)) {
$servers = [];
}
$count = count($servers);
// remove all occurrences which match $schemes, $server and $protocols
$servers = array_filter($servers, function ($s) use ($schemes, $server, $protocols) {
return $s['schemes'] !== $schemes || $s['server'] !== $server || $s['protocols'] !== $protocols;
});
$servers = array_values($servers); // reindex
$this->config->setAppValue('spreed', 'turn_servers', json_encode($servers));
if ($count > count($servers)) {
$output->writeln('<info>Deleted ' . $server . '.</info>');
} else {
$output->writeln('<info>There is nothing to delete.</info>');
}
return 0;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\Turn;
use OC\Core\Command\Base;
use OCP\IConfig;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ListCommand extends Base {
public function __construct(
private IConfig $config,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
parent::configure();
$this
->setName('talk:turn:list')
->setDescription('List TURN servers.');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$config = $this->config->getAppValue('spreed', 'turn_servers');
$servers = json_decode($config, true);
if (!is_array($servers)) {
$servers = [];
}
$this->writeMixedInOutputFormat($input, $output, $servers);
return 0;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\User;
use OC\Core\Command\Base;
use OCA\Talk\Manager;
use OCP\IUserManager;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class Remove extends Base {
public function __construct(
private IUserManager $userManager,
private Manager $manager,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('talk:user:remove')
->setDescription('Remove a user from all their rooms')
->addOption(
'user',
null,
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Remove the given users from all rooms'
)
->addOption(
'private-only',
null,
InputOption::VALUE_NONE,
'Only remove the user from private rooms, retaining membership in public and open conversations as well as one-to-ones'
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$userIds = $input->getOption('user');
$privateOnly = $input->getOption('private-only');
$users = [];
foreach ($userIds as $userId) {
$user = $this->userManager->get($userId);
if (!$user) {
$output->writeln('<error>' . sprintf("User '%s' not found.", $userId) . '</error>');
return 1;
}
$users[] = $user;
}
foreach ($users as $user) {
$this->manager->removeUserFromAllRooms($user, $privateOnly);
}
$output->writeln('<info>Users successfully removed from all rooms.</info>');
return 0;
}
}
+170
View File
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Command\User;
use OC\Core\Command\Base;
use OCA\Talk\Events\AAttendeeRemovedEvent;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Manager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\RoomService;
use OCP\IUserManager;
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 TransferOwnership extends Base {
private RoomService $roomService;
public function __construct(
private ParticipantService $participantService,
private Manager $manager,
private IUserManager $userManager,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('talk:user:transfer-ownership')
->setDescription('Adds the destination-user with the same participant type to all (not one-to-one) conversations of source-user')
->addArgument(
'source-user',
InputArgument::REQUIRED,
'Owner of conversations which shall be moved'
)
->addArgument(
'destination-user',
InputArgument::REQUIRED,
'User who will be the new owner of the conversations'
)
->addOption(
'include-non-moderator',
null,
InputOption::VALUE_NONE,
'Also include conversations where the source-user is a normal user'
)
->addOption(
'remove-source-user',
null,
InputOption::VALUE_NONE,
'Remove the source-user from the conversations'
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$sourceUID = $input->getArgument('source-user');
$destinationUID = $input->getArgument('destination-user');
$destinationUser = $this->userManager->get($destinationUID);
if ($destinationUser === null) {
$output->writeln('<error>Destination user could not be found.</error>');
return 1;
}
$includeNonModeratorRooms = $input->getOption('include-non-moderator');
$removeSourceUser = $input->getOption('remove-source-user');
$modified = $federatedRooms = 0;
$rooms = $this->manager->getRoomsForActor(Attendee::ACTOR_USERS, $sourceUID);
foreach ($rooms as $room) {
if ($room->getType() !== Room::TYPE_GROUP && $room->getType() !== Room::TYPE_PUBLIC) {
// Skip one-to-one, changelog and any other room types
continue;
}
if ($room->getObjectType() === Room::OBJECT_TYPE_SAMPLE) {
// Skip sample rooms
continue;
}
if ($room->isFederatedConversation()) {
$federatedRooms++;
continue;
}
$sourceParticipant = $this->participantService->getParticipantByActor($room, Attendee::ACTOR_USERS, $sourceUID);
if ($sourceParticipant->getAttendee()->getParticipantType() === Participant::USER_SELF_JOINED) {
continue;
}
if (!$includeNonModeratorRooms && !$sourceParticipant->hasModeratorPermissions()) {
continue;
}
try {
$destinationParticipant = $this->participantService->getParticipantByActor($room, Attendee::ACTOR_USERS, $destinationUser->getUID());
$targetType = $this->shouldUpdateParticipantType($sourceParticipant->getAttendee()->getParticipantType(), $destinationParticipant->getAttendee()->getParticipantType());
if ($targetType !== null) {
$this->participantService->updateParticipantType(
$room,
$destinationParticipant,
$sourceParticipant->getAttendee()->getParticipantType()
);
$modified++;
}
} catch (ParticipantNotFoundException $e) {
$this->participantService->addUsers($room, [
[
'actorType' => Attendee::ACTOR_USERS,
'actorId' => $destinationUser->getUID(),
'displayName' => $destinationUser->getDisplayName(),
'participantType' => $sourceParticipant->getAttendee()->getParticipantType(),
]
]);
$modified++;
}
if ($removeSourceUser) {
$this->participantService->removeAttendee($room, $sourceParticipant, AAttendeeRemovedEvent::REASON_REMOVED);
}
}
if ($federatedRooms > 0) {
$output->writeln('<comment>Could not transfer membership in ' . $federatedRooms . ' federated rooms.</comment>');
}
$output->writeln('<info>Added or promoted user ' . $destinationUser->getUID() . ' in ' . $modified . ' rooms.</info>');
return 0;
}
protected function shouldUpdateParticipantType(int $sourceParticipantType, int $destinationParticipantType): ?int {
if ($sourceParticipantType === Participant::OWNER) {
if ($destinationParticipantType === Participant::OWNER) {
return null;
}
return $sourceParticipantType;
}
if ($sourceParticipantType === Participant::MODERATOR) {
if ($destinationParticipantType === Participant::OWNER || $destinationParticipantType === Participant::MODERATOR) {
return null;
}
return $sourceParticipantType;
}
if ($sourceParticipantType === Participant::USER) {
if ($destinationParticipantType !== Participant::USER_SELF_JOINED) {
return null;
}
return $sourceParticipantType;
}
return null;
}
}