diff options
author | Joas Schilling <coding@schilljs.com> | 2022-11-10 19:15:42 +0300 |
---|---|---|
committer | Joas Schilling <coding@schilljs.com> | 2022-11-11 13:58:54 +0300 |
commit | efc00cf580c4aaf7805e7f813e10fefada4e44e9 (patch) | |
tree | 1c15deb97b27f3d597045323e4b136343951309f | |
parent | c30fdd409e61505dac1f993f24b90056c7c518d3 (diff) |
API endpoint to create and remove breakout rooms
Signed-off-by: Joas Schilling <coding@schilljs.com>
-rw-r--r-- | appinfo/info.xml | 2 | ||||
-rw-r--r-- | appinfo/routes/routesBreakoutRoomController.php | 38 | ||||
-rw-r--r-- | lib/Controller/BreakoutRoomController.php | 74 | ||||
-rw-r--r-- | lib/Manager.php | 28 | ||||
-rw-r--r-- | lib/Migration/Version16000Date20221110151714.php | 60 | ||||
-rw-r--r-- | lib/Model/BreakoutRoom.php | 35 | ||||
-rw-r--r-- | lib/Model/SelectHelper.php | 1 | ||||
-rw-r--r-- | lib/Room.php | 19 | ||||
-rw-r--r-- | lib/Service/BreakoutRoomService.php | 207 | ||||
-rw-r--r-- | lib/Service/RoomService.php | 27 | ||||
-rw-r--r-- | tests/php/Service/RoomServiceTest.php | 4 |
11 files changed, 485 insertions, 10 deletions
diff --git a/appinfo/info.xml b/appinfo/info.xml index 15db83ec5..a244379e9 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 ]]></description> - <version>16.0.0-beta.4</version> + <version>16.0.0-dev.1</version> <licence>agpl</licence> <author>Daniel Calviño Sánchez</author> diff --git a/appinfo/routes/routesBreakoutRoomController.php b/appinfo/routes/routesBreakoutRoomController.php new file mode 100644 index 000000000..eb038cb80 --- /dev/null +++ b/appinfo/routes/routesBreakoutRoomController.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); +/** + * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> + * + * @author Joas Schilling <coding@schilljs.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +$requirements = [ + 'apiVersion' => 'v(1)', + 'token' => '^[a-z0-9]{4,30}$', +]; + +return [ + 'ocs' => [ + /** @see \OCA\Talk\Controller\BreakoutRoomController::configureBreakoutRooms() */ + ['name' => 'BreakoutRoom#getPeersForCall', 'url' => '/api/{apiVersion}/breakout-rooms/{token}', 'verb' => 'POST', 'requirements' => $requirements], + /** @see \OCA\Talk\Controller\BreakoutRoomController::removeBreakoutRooms() */ + ['name' => 'BreakoutRoom#joinCall', 'url' => '/api/{apiVersion}/breakout-rooms/{token}', 'verb' => 'DELETE', 'requirements' => $requirements], + ], +]; diff --git a/lib/Controller/BreakoutRoomController.php b/lib/Controller/BreakoutRoomController.php new file mode 100644 index 000000000..ea93276f2 --- /dev/null +++ b/lib/Controller/BreakoutRoomController.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); +/** + * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> + * + * @author Joas Schilling <coding@schilljs.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\Talk\Controller; + +use InvalidArgumentException; +use OCA\Talk\Service\BreakoutRoomService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\IRequest; + +class BreakoutRoomController extends AEnvironmentAwareController { + protected BreakoutRoomService $breakoutRoomService; + + public function __construct(string $appName, + IRequest $request, + BreakoutRoomService $breakoutRoomService) { + parent::__construct($appName, $request); + $this->breakoutRoomService = $breakoutRoomService; + } + + /** + * @NoAdminRequired + * @RequireLoggedInModeratorParticipant + * + * @param int $mode + * @param int $amount + * @param string $attendeeMap + * @return DataResponse + */ + public function configureBreakoutRooms(int $mode, int $amount, string $attendeeMap): DataResponse { + try { + $this->breakoutRoomService->setupBreakoutRooms($this->room, $mode, $amount, $attendeeMap); + } catch (InvalidArgumentException $e) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + // FIXME make a useful response? + return new DataResponse(); + } + + /** + * @NoAdminRequired + * @RequireLoggedInModeratorParticipant + * + * @return DataResponse + */ + public function removeBreakoutRooms(): DataResponse { + $this->breakoutRoomService->removeBreakoutRooms($this->room); + return new DataResponse(); + } +} diff --git a/lib/Manager.php b/lib/Manager.php index 3b8814ec4..314a9d3c0 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -160,7 +160,6 @@ class Manager { $this->db, $this->dispatcher, $this->timeFactory, - $this->hasher, (int) $row['r_id'], (int) $row['type'], (int) $row['read_only'], @@ -185,7 +184,8 @@ class Manager { $lastMessage, $lobbyTimer, (string) $row['object_type'], - (string) $row['object_id'] + (string) $row['object_id'], + (int) $row['breakout_room_mode'] ); } @@ -766,6 +766,30 @@ class Manager { } /** + * @param string $objectType + * @param string $objectId + * @return Room[] + */ + public function getMultipleRoomsByObject(string $objectType, string $objectId): array { + $query = $this->db->getQueryBuilder(); + $helper = new SelectHelper(); + $helper->selectRoomsTable($query); + $query->from('talk_rooms', 'r') + ->where($query->expr()->eq('r.object_type', $query->createNamedParameter($objectType))) + ->andWhere($query->expr()->eq('r.object_id', $query->createNamedParameter($objectId))); + + $result = $query->executeQuery(); + $rooms = []; + while ($row = $result->fetch()) { + $room = $this->createRoomObject($row); + $rooms[] = $room; + } + $result->closeCursor(); + + return $rooms; + } + + /** * @param string|null $userId * @param string|null $sessionId * @return Room diff --git a/lib/Migration/Version16000Date20221110151714.php b/lib/Migration/Version16000Date20221110151714.php new file mode 100644 index 000000000..84bdf8802 --- /dev/null +++ b/lib/Migration/Version16000Date20221110151714.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> + * + * @author Joas Schilling <coding@schilljs.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\Talk\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Breakout rooms configuration + */ +class Version16000Date20221110151714 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): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $table = $schema->getTable('talk_rooms'); + + if (!$table->hasColumn('breakout_room_mode')) { + $table->addColumn('breakout_room_mode', Types::INTEGER, [ + 'default' => 0, + ]); + return $schema; + } + + return null; + } +} diff --git a/lib/Model/BreakoutRoom.php b/lib/Model/BreakoutRoom.php new file mode 100644 index 000000000..a410f4fc3 --- /dev/null +++ b/lib/Model/BreakoutRoom.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); +/** + * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> + * + * @author Joas Schilling <coding@schilljs.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\Talk\Model; + +class BreakoutRoom { + public const MODE_NOT_CONFIGURED = 0; + public const MODE_AUTOMATIC = 1; + public const MODE_MANUAL = 2; + public const MODE_FREE = 3; + + public const MINIMUM_ROOM_AMOUNT = 2; +} diff --git a/lib/Model/SelectHelper.php b/lib/Model/SelectHelper.php index 9ad2b5f09..d8f530410 100644 --- a/lib/Model/SelectHelper.php +++ b/lib/Model/SelectHelper.php @@ -54,6 +54,7 @@ class SelectHelper { ->addSelect($alias . 'message_expiration') ->addSelect($alias . 'remote_server') ->addSelect($alias . 'remote_token') + ->addSelect($alias . 'breakout_room_mode') ->selectAlias($alias . 'id', 'r_id'); } diff --git a/lib/Room.php b/lib/Room.php index 3042ebe9f..e0af364c1 100644 --- a/lib/Room.php +++ b/lib/Room.php @@ -38,7 +38,6 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\Comments\IComment; use OCP\EventDispatcher\IEventDispatcher; use OCP\IDBConnection; -use OCP\Security\IHasher; use OCP\Server; class Room { @@ -144,6 +143,8 @@ class Room { public const EVENT_BEFORE_SIGNALING_PROPERTIES = self::class . '::beforeSignalingProperties'; public const EVENT_BEFORE_SET_MESSAGE_EXPIRATION = self::class . '::beforeSetMessageExpiration'; public const EVENT_AFTER_SET_MESSAGE_EXPIRATION = self::class . '::afterSetMessageExpiration'; + public const EVENT_BEFORE_SET_BREAKOUT_ROOM_MODE = self::class . '::beforeSetBreakoutRoomMode'; + public const EVENT_AFTER_SET_BREAKOUT_ROOM_MODE = self::class . '::afterSetBreakoutRoomMode'; public const DESCRIPTION_MAXIMUM_LENGTH = 500; @@ -151,7 +152,6 @@ class Room { private IDBConnection $db; private IEventDispatcher $dispatcher; private ITimeFactory $timeFactory; - private IHasher $hasher; private int $id; private int $type; @@ -178,6 +178,7 @@ class Room { private ?IComment $lastMessage; private string $objectType; private string $objectId; + private int $breakoutRoomMode; protected ?string $currentUser = null; protected ?Participant $participant = null; @@ -186,7 +187,6 @@ class Room { IDBConnection $db, IEventDispatcher $dispatcher, ITimeFactory $timeFactory, - IHasher $hasher, int $id, int $type, int $readOnly, @@ -211,12 +211,12 @@ class Room { ?IComment $lastMessage, ?\DateTime $lobbyTimer, string $objectType, - string $objectId) { + string $objectId, + int $breakoutRoomMode) { $this->manager = $manager; $this->db = $db; $this->dispatcher = $dispatcher; $this->timeFactory = $timeFactory; - $this->hasher = $hasher; $this->id = $id; $this->type = $type; $this->readOnly = $readOnly; @@ -242,6 +242,7 @@ class Room { $this->lobbyTimer = $lobbyTimer; $this->objectType = $objectType; $this->objectId = $objectId; + $this->breakoutRoomMode = $breakoutRoomMode; } public function getId(): int { @@ -597,4 +598,12 @@ class Room { $this->activeGuests++; } } + + public function getBreakoutRoomMode(): int { + return $this->breakoutRoomMode; + } + + public function setBreakoutRoomMode(int $mode): void { + $this->breakoutRoomMode = $mode; + } } diff --git a/lib/Service/BreakoutRoomService.php b/lib/Service/BreakoutRoomService.php new file mode 100644 index 000000000..691e25bf4 --- /dev/null +++ b/lib/Service/BreakoutRoomService.php @@ -0,0 +1,207 @@ +<?php + +declare(strict_types=1); +/** + * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com> + * + * @author Joas Schilling <coding@schilljs.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\Talk\Service; + +use InvalidArgumentException; +use OCA\Talk\Manager; +use OCA\Talk\Model\Attendee; +use OCA\Talk\Model\BreakoutRoom; +use OCA\Talk\Participant; +use OCA\Talk\Room; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IL10N; + +class BreakoutRoomService { + protected Manager $manager; + protected RoomService $roomService; + protected ParticipantService $participantService; + protected IEventDispatcher $dispatcher; + protected IL10N $l; + + public function __construct(Manager $manager, + RoomService $roomService, + ParticipantService $participantService, + IEventDispatcher $dispatcher, + IL10N $l) { + $this->manager = $manager; + $this->roomService = $roomService; + $this->participantService = $participantService; + $this->dispatcher = $dispatcher; + $this->l = $l; + } + + /** + * @param Room $parent + * @param int $mode + * @psalm-param 0|1|2|3 $mode + * @param int $amount + * @param string $attendeeMap + * @return Room[] + * @throws InvalidArgumentException When the breakout rooms are configured already + */ + public function setupBreakoutRooms(Room $parent, int $mode, int $amount, string $attendeeMap): array { + if ($parent->getBreakoutRoomMode() !== BreakoutRoom::MODE_NOT_CONFIGURED) { + throw new InvalidArgumentException('room'); + } + + if (!$this->roomService->setBreakoutRoomMode($parent, $mode)) { + throw new InvalidArgumentException('mode'); + } + + if ($amount < BreakoutRoom::MINIMUM_ROOM_AMOUNT) { + throw new InvalidArgumentException('amount'); + } + + if ($mode === BreakoutRoom::MODE_MANUAL) { + try { + $attendeeMap = json_decode($attendeeMap, true, 2, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new InvalidArgumentException('map'); + } + } + + $breakoutRooms = $this->createBreakoutRooms($parent, $amount); + + $participants = $this->participantService->getParticipantsForRoom($parent); + // TODO Removing any non-users here as breakout rooms only support logged in users in version 1 + $participants = array_filter($participants, static fn (Participant $participant) => $participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS); + + $moderators = array_filter($participants, static fn (Participant $participant) => $participant->hasModeratorPermissions()); + $this->addModeratorsToBreakoutRooms($breakoutRooms, $moderators); + + $others = array_filter($participants, static fn (Participant $participant) => !$participant->hasModeratorPermissions()); + if ($mode === BreakoutRoom::MODE_AUTOMATIC) { + // Shuffle the attendees, so they are not always distributed in the same way + shuffle($others); + + $map = []; + foreach ($others as $index => $participant) { + $map[$index % $amount] ??= []; + $map[$index % $amount][] = $participant; + } + + $this->addOthersToBreakoutRooms($breakoutRooms, $map); + } elseif ($mode === BreakoutRoom::MODE_MANUAL) { + $map = []; + foreach ($others as $participant) { + $roomNumber = $attendeeMap[$participant->getAttendee()->getId()] ?? null; + if ($roomNumber === null) { + continue; + } + + $roomNumber = (int) $roomNumber; + + $map[$roomNumber] ??= []; + $map[$roomNumber][] = $participant; + } + + $this->addOthersToBreakoutRooms($breakoutRooms, $map); + } + + + return $breakoutRooms; + } + + /** + * @param Room[] $rooms + * @param Participant[] $moderators + */ + protected function addModeratorsToBreakoutRooms(array $rooms, array $moderators): void { + $moderatorsToAdd = []; + foreach ($moderators as $moderator) { + $attendee = $moderator->getAttendee(); + + $moderatorsToAdd[] = [ + 'actorType' => $attendee->getActorType(), + 'actorId' => $attendee->getActorId(), + 'displayName' => $attendee->getDisplayName(), + 'participantType' => $attendee->getParticipantType(), + ]; + } + + foreach ($rooms as $room) { + $this->participantService->addUsers($room, $moderatorsToAdd); + } + } + + /** + * @param array $rooms + * @param Participant[][] $participantsMap + */ + protected function addOthersToBreakoutRooms(array $rooms, array $participantsMap): void { + foreach ($rooms as $roomNumber => $room) { + $toAdd = []; + + $participants = $participantsMap[$roomNumber] ?? []; + foreach ($participants as $participant) { + $attendee = $participant->getAttendee(); + + $toAdd[] = [ + 'actorType' => $attendee->getActorType(), + 'actorId' => $attendee->getActorId(), + 'displayName' => $attendee->getDisplayName(), + 'participantType' => $attendee->getParticipantType(), + ]; + } + + if (empty($toAdd)) { + continue; + } + + $this->participantService->addUsers($room, $toAdd); + } + } + + public function createBreakoutRooms(Room $parent, int $amount): array { + // Safety caution cleaning up potential orphan rooms + $this->deleteBreakoutRooms($parent); + + $rooms = []; + for ($i = 1; $i <= $amount; $i++) { + $rooms[] = $this->roomService->createConversation( + $parent->getType(), + str_replace('{number}', (string) $i, $this->l->t('Room {number}')), + null, + 'room', + $parent->getToken() + ); + } + + return $rooms; + } + + public function removeBreakoutRooms(Room $parent): void { + $this->deleteBreakoutRooms($parent); + $this->roomService->setBreakoutRoomMode($parent, BreakoutRoom::MODE_NOT_CONFIGURED); + } + + protected function deleteBreakoutRooms(Room $parent): void { + $breakoutRooms = $this->manager->getMultipleRoomsByObject('room', $parent->getToken()); + foreach ($breakoutRooms as $breakoutRoom) { + $this->roomService->deleteRoom($breakoutRoom); + } + } +} diff --git a/lib/Service/RoomService.php b/lib/Service/RoomService.php index f0e9bec8e..7699d15fe 100644 --- a/lib/Service/RoomService.php +++ b/lib/Service/RoomService.php @@ -31,6 +31,7 @@ use OCA\Talk\Events\VerifyRoomPasswordEvent; use OCA\Talk\Exceptions\RoomNotFoundException; use OCA\Talk\Manager; use OCA\Talk\Model\Attendee; +use OCA\Talk\Model\BreakoutRoom; use OCA\Talk\Participant; use OCA\Talk\Room; use OCA\Talk\Webinary; @@ -577,6 +578,32 @@ class RoomService { $this->dispatcher->dispatch(Room::EVENT_AFTER_SET_MESSAGE_EXPIRATION, $event); } + public function setBreakoutRoomMode(Room $room, int $mode): bool { + if (!in_array($mode, [ + BreakoutRoom::MODE_NOT_CONFIGURED, + BreakoutRoom::MODE_AUTOMATIC, + BreakoutRoom::MODE_MANUAL, + BreakoutRoom::MODE_FREE + ], true)) { + return false; + } + + $event = new ModifyRoomEvent($room, 'breakoutRoomMode', $mode); + $this->dispatcher->dispatch(Room::EVENT_BEFORE_SET_BREAKOUT_ROOM_MODE, $event); + + $update = $this->db->getQueryBuilder(); + $update->update('talk_rooms') + ->set('breakout_room_mode', $update->createNamedParameter($mode, IQueryBuilder::PARAM_INT)) + ->where($update->expr()->eq('id', $update->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT))); + $update->executeStatement(); + + $room->setBreakoutRoomMode($mode); + + $this->dispatcher->dispatch(Room::EVENT_AFTER_SET_BREAKOUT_ROOM_MODE, $event); + + return true; + } + public function resetActiveSince(Room $room): bool { $update = $this->db->getQueryBuilder(); $update->update('talk_rooms') diff --git a/tests/php/Service/RoomServiceTest.php b/tests/php/Service/RoomServiceTest.php index 5c858f745..9d5ed9ed1 100644 --- a/tests/php/Service/RoomServiceTest.php +++ b/tests/php/Service/RoomServiceTest.php @@ -359,7 +359,6 @@ class RoomServiceTest extends TestCase { $this->createMock(IDBConnection::class), $dispatcher, $this->createMock(ITimeFactory::class), - $this->createMock(IHasher::class), 1, Room::TYPE_PUBLIC, Room::READ_WRITE, @@ -384,7 +383,8 @@ class RoomServiceTest extends TestCase { null, null, '', - '' + '', + 0 ); $verificationResult = $service->verifyPassword($room, '1234'); |