diff --git a/appinfo/info.xml b/appinfo/info.xml index 4072ee9b17c..803fefd646f 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -16,7 +16,7 @@ And in the works for the [coming versions](https://github.com/nextcloud/spreed/m ]]> - 10.0.0-dev.3 + 10.0.0-dev.4 agpl Daniel Calviño Sánchez diff --git a/appinfo/routes.php b/appinfo/routes.php index 722d0ed2af9..91d2eec4dd3 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -363,6 +363,56 @@ ], ], + /** + * Bridge settings + */ + [ + 'name' => 'MatterbridgeSettings#stopAllBridges', + 'url' => '/api/{apiVersion}/bridge', + 'verb' => 'DELETE', + 'requirements' => [ + 'apiVersion' => 'v1', + ], + ], + [ + 'name' => 'MatterbridgeSettings#getMatterbridgeVersion', + 'url' => '/api/{apiVersion}/bridge/version', + 'verb' => 'GET', + 'requirements' => [ + 'apiVersion' => 'v1', + ], + ], + + /** + * Bridges + */ + [ + 'name' => 'Matterbridge#getBridgeOfRoom', + 'url' => '/api/{apiVersion}/bridge/{token}', + 'verb' => 'GET', + 'requirements' => [ + 'apiVersion' => 'v1', + 'token' => '^[a-z0-9]{4,30}$', + ], + ], + [ + 'name' => 'Matterbridge#editBridgeOfRoom', + 'url' => '/api/{apiVersion}/bridge/{token}', + 'verb' => 'PUT', + 'requirements' => [ + 'apiVersion' => 'v1', + 'token' => '^[a-z0-9]{4,30}$', + ], + ], + [ + 'name' => 'Matterbridge#deleteBridgeOfRoom', + 'url' => '/api/{apiVersion}/bridge/{token}', + 'verb' => 'DELETE', + 'requirements' => [ + 'apiVersion' => 'v1', + 'token' => '^[a-z0-9]{4,30}$', + ], + ], /** * PublicShareAuth diff --git a/css/settings-admin.scss b/css/settings-admin.scss index ccf3407198c..cd8fb65aaae 100644 --- a/css/settings-admin.scss +++ b/css/settings-admin.scss @@ -46,15 +46,14 @@ } -.commands.section { +.commands.section, +.matterbridge.section { .icon-beta-feature { @include icon-color('customization', 'categories', $color-warning, 1, true); } +} - p.settings-hint > a { - text-decoration: underline !important; - } - +.commands.section { #commands_list { display: grid; grid-template-columns: minmax(100px, 200px) minmax(100px, 200px) 1fr minmax(100px, 200px) minmax(100px, 200px); diff --git a/img/bridge-bot.png b/img/bridge-bot.png new file mode 100644 index 00000000000..2bc5532dd1d Binary files /dev/null and b/img/bridge-bot.png differ diff --git a/lib/Controller/MatterbridgeController.php b/lib/Controller/MatterbridgeController.php new file mode 100644 index 00000000000..70489752c0e --- /dev/null +++ b/lib/Controller/MatterbridgeController.php @@ -0,0 +1,103 @@ + + * + * @author Julien Veyssier + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Talk\Controller; + +use OCA\Talk\Exceptions\ImpossibleToKillException; +use OCA\Talk\Manager; +use OCA\Talk\MatterbridgeManager; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\IRequest; + +class MatterbridgeController extends AEnvironmentAwareController { + /** @var string|null */ + protected $userId; + /** @var Manager */ + protected $manager; + /** @var MatterbridgeManager */ + protected $bridgeManager; + + public function __construct(string $appName, + ?string $UserId, + IRequest $request, + Manager $manager, + MatterbridgeManager $bridgeManager) { + parent::__construct($appName, $request); + $this->userId = $UserId; + $this->manager = $manager; + $this->bridgeManager = $bridgeManager; + } + + /** + * Get bridge information of one room + * + * @NoAdminRequired + * @RequireLoggedInModeratorParticipant + * + * @return DataResponse + */ + public function getBridgeOfRoom(): DataResponse { + $this->bridgeManager->checkBridge($this->room); + $bridge = $this->bridgeManager->getBridgeOfRoom($this->room); + return new DataResponse($bridge); + } + + /** + * Edit bridge information of one room + * + * @NoAdminRequired + * @RequireLoggedInModeratorParticipant + * + * @param bool $enabled + * @param array $parts + * @return DataResponse + */ + public function editBridgeOfRoom(bool $enabled, array $parts = []): DataResponse { + try { + $success = $this->bridgeManager->editBridgeOfRoom($this->room, $enabled, $parts); + } catch (ImpossibleToKillException $e) { + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_NOT_ACCEPTABLE); + } + return new DataResponse($success); + } + + /** + * Delete bridge of one room + * + * @NoAdminRequired + * @RequireLoggedInModeratorParticipant + * + * @return DataResponse + */ + public function deleteBridgeOfRoom(): DataResponse { + try { + $success = $this->bridgeManager->deleteBridgeOfRoom($this->room); + } catch (ImpossibleToKillException $e) { + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_NOT_ACCEPTABLE); + } + return new DataResponse($success); + } +} diff --git a/lib/Controller/MatterbridgeSettingsController.php b/lib/Controller/MatterbridgeSettingsController.php new file mode 100644 index 00000000000..0c0a8a77e41 --- /dev/null +++ b/lib/Controller/MatterbridgeSettingsController.php @@ -0,0 +1,77 @@ + + * + * @author Joas Schilling + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Talk\Controller; + +use OCA\Talk\MatterbridgeManager; +use OCA\Talk\Exceptions\ImpossibleToKillException; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\IRequest; + +class MatterbridgeSettingsController extends OCSController { + /** @var MatterbridgeManager */ + protected $bridgeManager; + + public function __construct(string $appName, + IRequest $request, + MatterbridgeManager $bridgeManager) { + parent::__construct($appName, $request); + $this->bridgeManager = $bridgeManager; + } + + /** + * Get Matterbridge version + * + * @return DataResponse + */ + public function getMatterbridgeVersion(): DataResponse { + $version = $this->bridgeManager->getCurrentVersionFromBinary(); + if ($version === null) { + return new DataResponse([ + 'error' => 'binary', + ], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse([ + 'version' => $version, + ]); + } + + /** + * Stop all bridges + * + * @return DataResponse + */ + public function stopAllBridges(): DataResponse { + try { + $success = $this->bridgeManager->stopAllBridges(); + } catch (ImpossibleToKillException $e) { + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_NOT_ACCEPTABLE); + } + return new DataResponse($success); + } +} diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index eab9e146f3c..4f516ce112b 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -249,6 +249,7 @@ public function index(string $token = '', string $callUser = '', string $passwor } $this->publishInitialStateForUser($user, $this->rootFolder, $this->appManager); + $this->initialStateService->provideInitialState('talk', 'enable_matterbridge', $this->serverConfig->getAppValue('spreed', 'enable_matterbridge', '0') === '1'); if (class_exists(LoadViewer::class)) { $this->eventDispatcher->dispatchTyped(new LoadViewer()); diff --git a/lib/Exceptions/ImpossibleToKillException.php b/lib/Exceptions/ImpossibleToKillException.php new file mode 100644 index 00000000000..9bcac0c31d7 --- /dev/null +++ b/lib/Exceptions/ImpossibleToKillException.php @@ -0,0 +1,28 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + + +namespace OCA\Talk\Exceptions; + +class ImpossibleToKillException extends \Exception { +} diff --git a/lib/MatterbridgeManager.php b/lib/MatterbridgeManager.php new file mode 100644 index 00000000000..bab70dbdf33 --- /dev/null +++ b/lib/MatterbridgeManager.php @@ -0,0 +1,654 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Talk; + +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IL10N; +use OCP\IUserManager; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\IURLGenerator; +use OC\Authentication\Token\IProvider as IAuthTokenProvider; +use OC\Authentication\Token\IToken; +use OCP\Security\ISecureRandom; +use OCP\IAvatarManager; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use Psr\Log\LoggerInterface; + +use OCA\Talk\Exceptions\ImpossibleToKillException; +use OCA\Talk\Exceptions\ParticipantNotFoundException; + +class MatterbridgeManager { + /** @var IDBConnection */ + private $db; + /** @var IConfig */ + private $config; + /** @var IAppData */ + private $appData; + /** @var IL10N */ + private $l; + /** @var IUserManager */ + private $userManager; + /** @var IAuthTokenProvider */ + private $tokenProvider; + /** @var ISecureRandom */ + private $random; + + public function __construct(IDBConnection $db, + IConfig $config, + IAppData $appData, + IURLGenerator $urlGenerator, + IUserManager $userManager, + Manager $manager, + IAuthTokenProvider $tokenProvider, + ISecureRandom $random, + IAvatarManager $avatarManager, + LoggerInterface $logger, + IL10N $l) { + $this->avatarManager = $avatarManager; + $this->db = $db; + $this->config = $config; + $this->urlGenerator = $urlGenerator; + $this->appData = $appData; + $this->userManager = $userManager; + $this->manager = $manager; + $this->tokenProvider = $tokenProvider; + $this->random = $random; + $this->logger = $logger; + $this->l = $l; + } + + /** + * Get bridge information for a specific room + * + * @param Room $room the room + * @return array decoded json bridge information + */ + public function getBridgeOfRoom(Room $room): array { + return $this->getBridgeFromDb($room); + } + + /** + * Edit bridge information for a room + * + * @param Room $room the room + * @param bool $enabled desired state of the bridge + * @param array $parts parts of the bridge (what it connects to) + * @return bool success + */ + public function editBridgeOfRoom(Room $room, bool $enabled, array $parts = []): bool { + $currentBridge = $this->getBridgeOfRoom($room); + $newBridge = [ + 'enabled' => $enabled, + 'pid' => isset($currentBridge['pid']) ? $currentBridge['pid'] : 0, + 'parts' => $parts, + ]; + + // edit/update the config file + $this->editBridgeConfig($room, $newBridge); + + // check state and manage the binary + $pid = $this->checkBridgeProcess($room, $newBridge); + $newBridge['pid'] = $pid; + + // save config + $this->saveBridgeToDb($room, $newBridge); + + return true; + } + + /** + * Delete bridge information for a room + * + * @param Room $room the room + * @return bool success + */ + public function deleteBridgeOfRoom(Room $room): bool { + // first potentially kill the process + $currentBridge = $this->getBridgeOfRoom($room); + $currentBridge['enabled'] = false; + $this->checkBridgeProcess($token, $currentBridge); + // then actually delete the config + $bridgeJSON = $this->config->deleteAppValue('spreed', 'bridge_' . $token); + return true; + } + + /** + * Check everything bridge-related is running fine + * For each room, check mattermost process respects desired state + */ + public function checkAllBridges(): void { + // TODO call this from time to time to make sure everything is running fine + $this->manager->forAllRooms(function ($room) { + if ($room->getType() === Room::GROUP_CALL || $room->getType() === Room::PUBLIC_CALL) { + $this->checkBridge($room); + } + }); + } + + /** + * For one room, check mattermost process respects desired state + * @param Room $room the room + */ + public function checkBridge(Room $room): void { + $bridge = $this->getBridgeOfRoom($room); + $pid = $this->checkBridgeProcess($room, $bridge); + if ($pid !== $bridge['pid']) { + // save the new PID if necessary + $bridge['pid'] = $pid; + $this->saveBridgeToDb($room, $bridge); + } + } + + private function getDataFolder(): ISimpleFolder { + try { + return $this->appData->getFolder('bridge'); + } catch (NotFoundException $e) { + return $this->appData->newFolder('bridge'); + } + } + + /** + * Edit the mattermost configuration file for one room + * This method takes care of connecting the bridge to the Talk room with a bot user + * + * @param Room $room the room + */ + private function editBridgeConfig(Room $room, array $newBridge): void { + // check bot user exists and is member of the room + // add the 'local' bridge part + $newBridge = $this->addLocalPart($room, $newBridge); + + // TODO adapt that to use appData + $configPath = sprintf('/tmp/bridge-%s.toml', $room->getToken()); + $configContent = $this->generateConfig($room, $newBridge); + file_put_contents($configPath, $configContent); + } + + /** + * Add a bridge part with bot credentials to connect to the room + * + * @param Room $room the room + * @param array $bridge bridge information + * @return array the bridge with local part added + */ + private function addLocalPart(Room $room, array $bridge): array { + $botInfo = $this->checkBotUser($room, $bridge['enabled']); + $localPart = [ + 'type' => 'nctalk', + 'login' => $botInfo['id'], + 'password' => $botInfo['password'], + 'channel' => $room->getToken(), + ]; + array_push($bridge['parts'], $localPart); + return $bridge; + } + + /** + * routine to check the Nextcloud bot user exists (and create it if not) + * and to add it in the room in necessary + * and to revoke its old app token + * and to generate a new app token (used to connect via matterbridge) + * + * @param Room $room the room + * @param bool $create whether we should generate a new app token or not + * @return array Bot user information (username and app token). token is an empty string if creation was not asked. + */ + private function checkBotUser(Room $room, bool $create): array { + $botUserId = 'bridge-bot'; + // check if user exists and create it if necessary + if (!$this->userManager->userExists($botUserId)) { + $pass = md5(strval(rand())); + $this->config->setAppValue('spreed', 'bridge_bot_password', $pass); + $botUser = $this->userManager->createUser($botUserId, $pass); + // set avatar + $avatar = $this->avatarManager->getAvatar($botUserId); + $imageData = file_get_contents(\OC::$SERVERROOT . '/apps/spreed/img/bridge-bot.png'); + $avatar->set($imageData); + } else { + $botUser = $this->userManager->get($botUserId); + } + + // check user is member of the room + try { + $participant = $room->getParticipant($botUserId); + } catch (ParticipantNotFoundException $e) { + $room->addUsers([ + 'userId' => $botUserId, + 'participantType' => Participant::USER, + ]); + } + + // delete old bot app tokens for this room + $tokenName = 'spreed_' . $room->getToken(); + $tokens = $this->tokenProvider->getTokenByUser($botUserId); + foreach ($tokens as $t) { + if ($t->getName() === $tokenName) { + $this->tokenProvider->invalidateTokenById($botUserId, $t->getId()); + } + } + + if ($create) { + // generate app token for the bot + $appToken = $this->random->generate(72, ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_DIGITS); + $botPassword = $this->config->getAppValue('spreed', 'bridge_bot_password', ''); + $generatedToken = $this->tokenProvider->generateToken( + $appToken, + $botUserId, + $botUserId, + $botPassword, + $tokenName, + IToken::PERMANENT_TOKEN, + IToken::REMEMBER + ); + } else { + $appToken = ''; + } + + return [ + 'id' => $botUserId, + 'password' => $appToken, + ]; + } + + /** + * Actually generate the matterbridge configuration file content for one bridge (one room) + * It basically add a pair of sections for each part: authentication and target channel + * + * @param Room $room the room + * @return string config file content + */ + private function generateConfig(Room $room, array $bridge): string { + $content = ''; + foreach ($bridge['parts'] as $k => $part) { + if ($part['type'] === 'nctalk') { + $content .= sprintf('[%s.%s]', $part['type'], $k) . "\n"; + if (isset($part['server']) && $part['server'] !== '') { + $serverUrl = $part['server']; + } else { + $serverUrl = preg_replace('/\/+$/', '', $this->urlGenerator->getAbsoluteURL('')); + // TODO remove that + //$serverUrl = preg_replace('/https:/', 'http:', $serverUrl); + } + $content .= sprintf(' Server = "%s"', $serverUrl) . "\n"; + $content .= sprintf(' Login = "%s"', $part['login']) . "\n"; + $content .= sprintf(' Password = "%s"', $part['password']) . "\n"; + $content .= ' PrefixMessagesWithNick = true' . "\n"; + $content .= ' RemoteNickFormat="[{PROTOCOL}] <{NICK}> "' . "\n\n"; + } elseif ($part['type'] === 'mattermost') { + // remove protocol from server URL + if (preg_match('/^https?:/', $part['server'])) { + $part['server'] = $this->cleanUrl($part['server']); + } + $content .= sprintf('[%s]', $part['type']) . "\n"; + $content .= sprintf(' [%s.%s]', $part['type'], $k) . "\n"; + $content .= sprintf(' Server = "%s"', $part['server']) . "\n"; + $content .= sprintf(' Team = "%s"', $part['team']) . "\n"; + $content .= sprintf(' Login = "%s"', $part['login']) . "\n"; + $content .= sprintf(' Password = "%s"', $part['password']) . "\n"; + $content .= ' PrefixMessagesWithNick = true' . "\n"; + $content .= ' RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n"; + } elseif ($part['type'] === 'matrix') { + $content .= sprintf('[%s.%s]', $part['type'], $k) . "\n"; + $content .= sprintf(' Server = "%s"', $part['server']) . "\n"; + $content .= sprintf(' Login = "%s"', $part['login']) . "\n"; + $content .= sprintf(' Password = "%s"', $part['password']) . "\n"; + $content .= ' PrefixMessagesWithNick = true' . "\n"; + $content .= ' NoHomeServerSuffix = true' . "\n"; + $content .= ' RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n"; + } elseif ($part['type'] === 'zulip') { + $content .= sprintf('[%s.%s]', $part['type'], $k) . "\n"; + $content .= sprintf(' Server = "%s"', $part['server']) . "\n"; + $content .= sprintf(' Login = "%s"', $part['login']) . "\n"; + $content .= sprintf(' Token = "%s"', $part['token']) . "\n"; + $content .= ' PrefixMessagesWithNick = true' . "\n"; + $content .= ' RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n"; + } elseif ($part['type'] === 'rocketchat') { + // include # in channel + if (!preg_match('/^#/', $part['channel'])) { + $bridge['parts'][$k]['channel'] = '#' . $part['channel']; + } + $content .= sprintf('[%s.%s]', $part['type'], $k) . "\n"; + $content .= sprintf(' Server = "%s"', $part['server']) . "\n"; + $content .= sprintf(' Login = "%s"', $part['login']) . "\n"; + $content .= sprintf(' Password = "%s"', $part['password']) . "\n"; + $content .= ' PrefixMessagesWithNick = true' . "\n"; + $content .= ' RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n"; + } elseif ($part['type'] === 'slack') { + // do not include # in channel + if (preg_match('/^#/', $part['channel'])) { + $bridge['parts'][$k]['channel'] = preg_replace('/^#+/', '', $part['channel']); + } + $content .= sprintf('[%s.%s]', $part['type'], $k) . "\n"; + $content .= sprintf(' Token = "%s"', $part['token']) . "\n"; + $content .= ' PrefixMessagesWithNick = true' . "\n"; + $content .= ' RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n"; + } elseif ($part['type'] === 'discord') { + // do not include # in channel + if (preg_match('/^#/', $part['channel'])) { + $bridge['parts'][$k]['channel'] = preg_replace('/^#+/', '', $part['channel']); + } + $content .= sprintf('[%s.%s]', $part['type'], $k) . "\n"; + $content .= sprintf(' Token = "%s"', $part['token']) . "\n"; + $content .= sprintf(' Server = "%s"', $part['server']) . "\n"; + $content .= ' PrefixMessagesWithNick = true' . "\n"; + $content .= ' RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n"; + } elseif ($part['type'] === 'telegram') { + $content .= sprintf('[%s.%s]', $part['type'], $k) . "\n"; + $content .= sprintf(' Token = "%s"', $part['token']) . "\n"; + $content .= ' PrefixMessagesWithNick = true' . "\n"; + $content .= ' RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n"; + } elseif ($part['type'] === 'steam') { + $content .= sprintf('[%s.%s]', $part['type'], $k) . "\n"; + $content .= sprintf(' Login = "%s"', $part['login']) . "\n"; + $content .= sprintf(' Password = "%s"', $part['password']) . "\n"; + $content .= ' PrefixMessagesWithNick = true' . "\n"; + $content .= ' RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n"; + } elseif ($part['type'] === 'irc') { + // include # in channel + if (!preg_match('/^#/', $part['channel'])) { + $bridge['parts'][$k]['channel'] = '#' . $part['channel']; + } + $content .= sprintf('[%s.%s]', $part['type'], $k) . "\n"; + $content .= sprintf(' Server = "%s"', $part['server']) . "\n"; + $content .= sprintf(' Nick = "%s"', $part['nick']) . "\n"; + $content .= sprintf(' Password = "%s"', $part['password']) . "\n"; + $content .= ' PrefixMessagesWithNick = true' . "\n"; + $content .= ' RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n"; + } elseif ($part['type'] === 'msteams') { + $content .= sprintf('[%s.%s]', $part['type'], $k) . "\n"; + $content .= sprintf(' TenantID = "%s"', $part['tenantid']) . "\n"; + $content .= sprintf(' ClientID = "%s"', $part['clientid']) . "\n"; + $content .= sprintf(' TeamID = "%s"', $part['teamid']) . "\n"; + $content .= ' PrefixMessagesWithNick = true' . "\n"; + $content .= ' RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n"; + } elseif ($part['type'] === 'xmpp') { + $content .= sprintf('[%s.%s]', $part['type'], $k) . "\n"; + $content .= sprintf(' Server = "%s"', $part['server']) . "\n"; + $content .= sprintf(' Jid = "%s"', $part['jid']) . "\n"; + $content .= sprintf(' Password = "%s"', $part['password']) . "\n"; + $content .= sprintf(' Muc = "%s"', $part['muc']) . "\n"; + $content .= sprintf(' Nick = "%s"', $part['nick']) . "\n"; + $content .= ' PrefixMessagesWithNick = true' . "\n"; + $content .= ' RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n"; + } + } + + $content .= '[[gateway]]' . "\n"; + $content .= ' name = "myGateway"' . "\n"; + $content .= ' enable = true' . "\n\n"; + + foreach ($bridge['parts'] as $k => $part) { + $content .= '[[gateway.inout]]' . "\n"; + $content .= sprintf(' account = "%s.%s"', $part['type'], $k) . "\n"; + if (in_array($part['type'], ['zulip', 'discord', 'xmpp', 'irc', 'slack', 'rocketchat', 'mattermost', 'matrix', 'nctalk'])) { + $content .= sprintf(' channel = "%s"', $part['channel']) . "\n\n"; + } elseif ($part['type'] === 'msteams') { + $content .= sprintf(' threadId = "%s"', $part['threadid']) . "\n\n"; + } elseif (in_array($part['type'], ['telegram', 'steam'])) { + $content .= sprintf(' chatid = "%s"', $part['chatid']) . "\n\n"; + } + } + + return $content; + } + + /** + * Remove the scheme from an URL and add port + */ + private function cleanUrl(string $url): string { + $uo = parse_url($url); + $result = $uo['host']; + if ($uo['scheme'] === 'https' && !isset($uo['port'])) { + $result .= ':443'; + } elseif (isset($uo['port'])) { + $result .= ':' . $uo['port']; + } + $result .= $uo['path']; + return $result; + } + + /** + * check if a bridge process is running + * + * @param Room $room the room + * @param array $bridge bridge information + * @return int the corresponding matterbridge process ID, 0 if none + */ + private function checkBridgeProcess(Room $room, array $bridge): int { + $pid = 0; + + if (isset($bridge['pid']) && intval($bridge['pid']) !== 0) { + // config : there is a PID stored + $pid = intval($bridge['pid']); + $isRunning = $this->isRunning($pid); + // if bridge running and enabled is false : kill it + if ($isRunning) { + if ($bridge['enabled']) { + $this->logger->info('Process running AND bridge enabled in config : doing nothing'); + } else { + $this->logger->info('Process running AND bridge disabled in config : KILL ' . $pid); + $killed = $this->killPid($pid); + if ($killed) { + $pid = 0; + } else { + $this->logger->info('Impossible to kill ' . $pid); + throw new ImpossibleToKillException('Impossible to kill bridge process [' . $pid . ']'); + } + } + } else { + // no process found + if ($bridge['enabled']) { + $this->logger->info('Process not found AND bridge enabled in config : relaunching'); + $pid = $this->launchMatterbridge($room); + } else { + $this->logger->info('Process not found AND bridge disabled in config : doing nothing'); + } + } + } elseif ($bridge['enabled']) { + // config : no PID stored + // config : enabled => launch it + $pid = $this->launchMatterbridge($room); + $this->logger->info('Launch process, PID is '.$pid); + } else { + $this->logger->info('No PID defined in config AND bridge disabled in config : doing nothing'); + } + + return $pid; + } + + /** + * Actually launch a matterbridge process for a room + * + * @param Room $room the room + * @return int the corresponding matterbridge process ID, 0 if it failed + */ + private function launchMatterbridge(Room $room): int { + $binaryPath = $this->config->getAppValue('spreed', 'matterbridge_binary'); + // TODO this should be in appdata + $configPath = sprintf('/tmp/bridge-%s.toml', $room->getToken()); + $outputPath = sprintf('/tmp/bridge-%s.log', $room->getToken()); + $cmd = sprintf('%s -conf %s', $binaryPath, $configPath); + $pid = exec(sprintf('%s > %s 2>&1 & echo $!', $cmd, $outputPath), $output, $ret); + $pid = intval($pid); + if ($ret !== 0) { + $pid = 0; + } + return $pid; + } + + /** + * kill the mattermost processes (owned by web server unix user) that do not match with any room + */ + public function killZombieBridges(): void { + // get list of running matterbridge processes + $cmd = 'ps -ux | grep "commands/matterbridge" | grep -v grep | awk \'{print $2}\''; + exec($cmd, $output, $ret); + $runningPidList = []; + foreach ($output as $o) { + array_push($runningPidList, intval($o)); + } + // get list of what should be running + $expectedPidList = []; + $this->manager->forAllRooms(function ($room) use (&$expectedPidList) { + $bridge = $this->getBridgeOfRoom($room); + if ($bridge['enabled'] && $bridge['pid'] !== 0) { + array_push($expectedPidList, intval($bridge['pid'])); + } + }); + // kill what should not be running + foreach ($runningPidList as $runningPid) { + if (!in_array($runningPid, $expectedPidList)) { + $this->killPid($runningPid); + } + } + } + + /** + * Utility to kill a process + * + * @param int $pid the process ID to kill + * @return bool if it was successfully killed + */ + private function killPid(int $pid): bool { + // kill + exec(sprintf('kill -9 %d', $pid), $output, $ret); + // check the process is gone + $isStillRunning = $this->isRunning($pid); + return (intval($ret) === 0 && !$isStillRunning); + } + + /** + * Check if a process is running + * + * @param int $pid the process ID + * @return bool true if it's running + */ + private function isRunning(int $pid): bool { + try { + $result = shell_exec(sprintf('ps %d', $pid)); + if (count(preg_split('/\n/', $result)) > 2) { + return true; + } + } catch (Exception $e) { + } + return false; + } + + /** + * Stop all bridges + * + * @return bool success + */ + public function stopAllBridges(): bool { + $this->manager->forAllRooms(function ($room) { + if ($room->getType() === Room::GROUP_CALL || $room->getType() === Room::PUBLIC_CALL) { + $bridge = $this->getBridgeOfRoom($room); + // disable bridge in stored config + $bridge['enabled'] = false; + $this->saveBridgeToDb($room, $bridge); + // this will kill the bridge process + $this->checkBridgeProcess($token, $currentBridge); + } + }); + + // finally kill all potential zombie matterbridge processes + $this->killZombieBridges(); + return true; + } + + /** + * Get bridge information for one room + * + * @param Room $room the room + * @return array decoded json array + */ + private function getBridgeFromDb(Room $room): array { + $roomId = $room->getId(); + + $qb = $this->db->getQueryBuilder(); + $qb->select('json_values') + ->from('talk_bridges', 'b') + ->where( + $qb->expr()->eq('room_id', $qb->createNamedParameter($roomId, IQueryBuilder::PARAM_INT)) + ); + $req = $qb->execute(); + $jsonValues = '{"enabled":false,"pid":0,"parts":[]}'; + while ($row = $req->fetch()) { + $jsonValues = $row['json_values']; + break; + } + $req->closeCursor(); + + return json_decode($jsonValues, true); + } + + /** + * Save bridge information for one room + * + * @param Room $room the room + * @param array $bridge bridge values + */ + private function saveBridgeToDb(Room $room, array $bridge): void { + $roomId = $room->getId(); + $jsonValues = json_encode($bridge); + + $qb = $this->db->getQueryBuilder(); + try { + $qb->insert('talk_bridges') + ->values([ + 'room_id' => $qb->createNamedParameter($roomId, IQueryBuilder::PARAM_INT), + 'json_values' => $qb->createNamedParameter($jsonValues, IQueryBuilder::PARAM_STR), + ]); + $req = $qb->execute(); + } catch (UniqueConstraintViolationException $e) { + $qb = $this->db->getQueryBuilder(); + $qb->update('talk_bridges'); + $qb->set('json_values', $qb->createNamedParameter($jsonValues, IQueryBuilder::PARAM_STR)); + $qb->where( + $qb->expr()->eq('room_id', $qb->createNamedParameter($roomId, IQueryBuilder::PARAM_INT)) + ); + $req = $qb->execute(); + } + } + + public function getCurrentVersionFromBinary(): ?string { + $binaryPath = $this->config->getAppValue('spreed', 'matterbridge_binary'); + if (!file_exists($binaryPath)) { + return null; + } + + $cmd = escapeshellcmd($binaryPath) . ' ' . escapeshellarg('-version'); + @exec($cmd, $output, $returnCode); + + if ($returnCode !== 0) { + return null; + } + + return trim(implode("\n", $output)); + } +} diff --git a/lib/Migration/Version10000Date20200819121721.php b/lib/Migration/Version10000Date20200819121721.php new file mode 100644 index 00000000000..c15fa02dbe8 --- /dev/null +++ b/lib/Migration/Version10000Date20200819121721.php @@ -0,0 +1,65 @@ + + * + * @author Julien Veyssier + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Talk\Migration; + +use Closure; +use Doctrine\DBAL\Types\Type; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version10000Date20200819121721 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('talk_bridges')) { + $table = $schema->createTable('talk_bridges'); + $table->addColumn('id', Type::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('room_id', Type::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('json_values', Type::TEXT, [ + 'notnull' => true, + ]); + $table->addUniqueIndex(['room_id'], 'tbr_room_id'); + + $table->setPrimaryKey(['id']); + } + + return $schema; + } +} diff --git a/lib/Settings/Admin/AdminSettings.php b/lib/Settings/Admin/AdminSettings.php index 2ab67c38eab..ec47c257f40 100644 --- a/lib/Settings/Admin/AdminSettings.php +++ b/lib/Settings/Admin/AdminSettings.php @@ -23,6 +23,7 @@ namespace OCA\Talk\Settings\Admin; +use OCA\Talk\MatterbridgeManager; use OCA\Talk\Config; use OCA\Talk\Model\Command; use OCA\Talk\Participant; @@ -50,6 +51,8 @@ class AdminSettings implements ISettings { private $initialStateService; /** @var ICacheFactory */ private $memcacheFactory; + /** @var MatterbridgeManager */ + private $bridgeManager; /** @var IUser */ private $currentUser; /** @var IL10N */ @@ -62,6 +65,7 @@ public function __construct(Config $talkConfig, CommandService $commandService, IInitialStateService $initialStateService, ICacheFactory $memcacheFactory, + MatterbridgeManager $bridgeManager, IUserSession $userSession, IL10N $l10n, IFactory $l10nFactory) { @@ -70,6 +74,7 @@ public function __construct(Config $talkConfig, $this->commandService = $commandService; $this->initialStateService = $initialStateService; $this->memcacheFactory = $memcacheFactory; + $this->bridgeManager = $bridgeManager; $this->currentUser = $userSession->getUser(); $this->l10n = $l10n; $this->l10nFactory = $l10nFactory; @@ -82,6 +87,7 @@ public function getForm(): TemplateResponse { $this->initGeneralSettings(); $this->initAllowedGroups(); $this->initCommands(); + $this->initMatterbridge(); $this->initStunServers(); $this->initTurnServers(); $this->initSignalingServers(); @@ -112,6 +118,18 @@ protected function initCommands(): void { $this->initialStateService->provideInitialState('talk', 'commands', $result); } + protected function initMatterbridge(): void { + $this->initialStateService->provideInitialState( + 'talk', 'matterbridge_version', + (string) $this->bridgeManager->getCurrentVersionFromBinary() + ); + + $this->initialStateService->provideInitialState( + 'talk', 'matterbridge_enable', + $this->serverConfig->getAppValue('spreed', 'enable_matterbridge', '0') === '1' + ); + } + protected function initStunServers(): void { $this->initialStateService->provideInitialState('talk', 'stun_servers', $this->talkConfig->getStunServers()); $this->initialStateService->provideInitialState('talk', 'has_internet_connection', $this->serverConfig->getSystemValueBool('has_internet_connection', true)); diff --git a/src/components/AdminSettings/MatterbridgeIntegration.vue b/src/components/AdminSettings/MatterbridgeIntegration.vue new file mode 100644 index 00000000000..4bc8b4a4869 --- /dev/null +++ b/src/components/AdminSettings/MatterbridgeIntegration.vue @@ -0,0 +1,183 @@ + + + + + + {{ t('spreed', 'Matterbridge integration') }} + + {{ t('spreed', 'Beta') }} + + + + + + + {{ installedVersion }} + + + + + {{ t('spreed', 'Enable Matterbridge integration') }} + + + + + + + + + + {{ t('spreed', 'Downloading …') }} + + + {{ t('spreed', 'Install Talk Matterbridge') }} + + + + + + + + + diff --git a/src/components/RightSidebar/Matterbridge/BridgePart.vue b/src/components/RightSidebar/Matterbridge/BridgePart.vue new file mode 100644 index 00000000000..a3781d95d4c --- /dev/null +++ b/src/components/RightSidebar/Matterbridge/BridgePart.vue @@ -0,0 +1,155 @@ + + + + + + + {{ type.name }} + + + + + + + {{ field.placeholder }} + + + + + + + + + diff --git a/src/components/RightSidebar/Matterbridge/MatterbridgeSettings.vue b/src/components/RightSidebar/Matterbridge/MatterbridgeSettings.vue new file mode 100644 index 00000000000..6c699736239 --- /dev/null +++ b/src/components/RightSidebar/Matterbridge/MatterbridgeSettings.vue @@ -0,0 +1,526 @@ + + + + + + + + + {{ t('spreed', 'Bridge with other services') }} + + + {{ t('spreed', 'You can bridge channels from various instant messaging systems with Matterbridge.') }} + + {{ t('spreed', 'More info on Matterbridge.') }} + + + + + + {{ t('spreed', 'Enabled') }} + + + + {{ t('spreed', 'Save') }} + + + + + + + + + + + + + + diff --git a/src/components/RightSidebar/RightSidebar.vue b/src/components/RightSidebar/RightSidebar.vue index 1796e3a7be5..1519f53abd1 100644 --- a/src/components/RightSidebar/RightSidebar.vue +++ b/src/components/RightSidebar/RightSidebar.vue @@ -67,11 +67,14 @@ + diff --git a/tests/php/Settings/Admin/AdminSettingsTest.php b/tests/php/Settings/Admin/AdminSettingsTest.php index 8022ce3a66e..95807ee2dd8 100644 --- a/tests/php/Settings/Admin/AdminSettingsTest.php +++ b/tests/php/Settings/Admin/AdminSettingsTest.php @@ -24,6 +24,7 @@ namespace OCA\Talk\Tests\php\Settings\Admin; use OCA\Talk\Config; +use OCA\Talk\MatterbridgeManager; use OCA\Talk\Service\CommandService; use OCA\Talk\Settings\Admin\AdminSettings; use OCP\ICacheFactory; @@ -46,14 +47,16 @@ class AdminSettingsTest extends \Test\TestCase { protected $initialState; /** @var ICacheFactory|MockObject */ protected $cacheFactory; - /** @var AdminSettings */ - protected $admin; + /** @var MatterbridgeManager|MockObject */ + protected $matterbridgeManager; /** @var IUserSession|MockObject */ protected $userSession; /** @var IL10N|MockObject */ protected $l10n; /** @var IFactory|MockObject */ protected $l10nFactory; + /** @var AdminSettings */ + protected $admin; public function setUp(): void { parent::setUp(); @@ -63,6 +66,7 @@ public function setUp(): void { $this->commandService = $this->createMock(CommandService::class); $this->initialState = $this->createMock(IInitialStateService::class); $this->cacheFactory = $this->createMock(ICacheFactory::class); + $this->matterbridgeManager = $this->createMock(MatterbridgeManager::class); $this->userSession = $this->createMock(IUserSession::class); $this->l10n = $this->createMock(IL10N::class); $this->l10nFactory = $this->createMock(IFactory::class); @@ -82,6 +86,7 @@ protected function getAdminSettings(array $methods = []): AdminSettings { $this->commandService, $this->initialState, $this->cacheFactory, + $this->matterbridgeManager, $this->userSession, $this->l10n, $this->l10nFactory @@ -95,6 +100,7 @@ protected function getAdminSettings(array $methods = []): AdminSettings { $this->commandService, $this->initialState, $this->cacheFactory, + $this->matterbridgeManager, $this->userSession, $this->l10n, $this->l10nFactory,
+ {{ installedVersion }} +
+ + {{ t('spreed', 'Enable Matterbridge integration') }} +
+ + + {{ t('spreed', 'Downloading …') }} + + + {{ t('spreed', 'Install Talk Matterbridge') }} + +
+ {{ t('spreed', 'You can bridge channels from various instant messaging systems with Matterbridge.') }} + + {{ t('spreed', 'More info on Matterbridge.') }} + +