Files
nextcloud-spreed/lib/Service/BreakoutRoomService.php
Joas Schilling 38a244b86b fix(api): Properly typed breakoutRoom update
Signed-off-by: Joas Schilling <coding@schilljs.com>
2024-09-11 09:45:24 +02:00

573 lines
18 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use InvalidArgumentException;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Config;
use OCA\Talk\Events\AAttendeeRemovedEvent;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\RoomProperty\BreakoutRoomModeException;
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;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IL10N;
use OCP\Notification\IManager as INotificationManager;
class BreakoutRoomService {
public function __construct(
protected Config $config,
protected Manager $manager,
protected RoomService $roomService,
protected ParticipantService $participantService,
protected ChatManager $chatManager,
protected INotificationManager $notificationManager,
protected ITimeFactory $timeFactory,
protected IEventDispatcher $dispatcher,
protected IL10N $l,
) {
}
/**
* @param string $map
* @param int $max
* @return array
*/
protected function parseAttendeeMap(string $map, int $max): array {
if ($map === '') {
return [];
}
try {
$attendeeMap = json_decode($map, true, 2, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new InvalidArgumentException('attendeeMap');
}
if (!is_array($attendeeMap)) {
throw new InvalidArgumentException('attendeeMap');
}
if (empty($attendeeMap)) {
return [];
}
try {
$attendeeMap = array_filter($attendeeMap, static fn (int $roomNumber, int $attendeeId) => true, ARRAY_FILTER_USE_BOTH);
} catch (\Throwable) {
throw new InvalidArgumentException('attendeeMap');
}
if (empty($attendeeMap)) {
return [];
}
if (max($attendeeMap) >= $max) {
throw new InvalidArgumentException('attendeeMap');
}
if (min($attendeeMap) < 0) {
throw new InvalidArgumentException('attendeeMap');
}
if (min(array_keys($attendeeMap)) <= 0) {
throw new InvalidArgumentException('attendeeMap');
}
return $attendeeMap;
}
/**
* @param Room $parent
* @param 0|1|2|3 $mode
* @psalm-param BreakoutRoom::MODE_* $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 (!$this->config->isBreakoutRoomsEnabled()) {
throw new InvalidArgumentException('config');
}
if ($parent->getBreakoutRoomMode() !== BreakoutRoom::MODE_NOT_CONFIGURED) {
throw new InvalidArgumentException('room');
}
if ($parent->getType() !== Room::TYPE_GROUP) {
// Can only do breakout rooms in group rooms
throw new InvalidArgumentException('room');
}
if ($parent->getObjectType() === BreakoutRoom::PARENT_OBJECT_TYPE) {
// Can not nest breakout rooms
throw new InvalidArgumentException('room');
}
try {
$this->roomService->setBreakoutRoomMode($parent, $mode);
} catch (BreakoutRoomModeException) {
throw new InvalidArgumentException('mode');
}
if ($amount < BreakoutRoom::MINIMUM_ROOM_AMOUNT) {
throw new InvalidArgumentException('amount');
}
if ($amount > BreakoutRoom::MAXIMUM_ROOM_AMOUNT) {
throw new InvalidArgumentException('amount');
}
if ($mode === BreakoutRoom::MODE_MANUAL) {
$cleanedMap = $this->parseAttendeeMap($attendeeMap, $amount);
}
$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) {
if (!isset($cleanedMap[$participant->getAttendee()->getId()])) {
continue;
}
$roomNumber = (int)$cleanedMap[$participant->getAttendee()->getId()];
$map[$roomNumber] ??= [];
$map[$roomNumber][] = $participant;
}
$this->addOthersToBreakoutRooms($breakoutRooms, $map);
}
return $breakoutRooms;
}
/**
* @param Room $parent
* @param string $attendeeMap
* @return Room[]
* @throws InvalidArgumentException When the map was invalid, breakout rooms are disabled or not configured for this conversation
*/
public function applyAttendeeMap(Room $parent, string $attendeeMap): array {
if (!$this->config->isBreakoutRoomsEnabled()) {
throw new InvalidArgumentException('config');
}
if ($parent->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
throw new InvalidArgumentException('mode');
}
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());
$amount = count($breakoutRooms);
usort($breakoutRooms, static function (Room $roomA, Room $roomB) {
return $roomA->getId() - $roomB->getId();
});
$cleanedMap = $this->parseAttendeeMap($attendeeMap, $amount);
$attendeeIds = array_keys($cleanedMap);
$participants = $this->participantService->getParticipantsForRoom($parent);
$participants = array_filter($participants, static fn (Participant $participant) => in_array($participant->getAttendee()->getId(), $attendeeIds, true));
// 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);
$userIds = array_map(static fn (Participant $participant) => $participant->getAttendee()->getActorId(), $participants);
$removals = [];
foreach ($breakoutRooms as $breakoutRoom) {
$breakoutRoomParticipants = $this->participantService->getParticipantsForRoom($breakoutRoom);
foreach ($breakoutRoomParticipants as $participant) {
$attendee = $participant->getAttendee();
if ($attendee->getActorType() === Attendee::ACTOR_USERS && in_array($attendee->getActorId(), $userIds, true)) {
if ($participant->hasModeratorPermissions()) {
// Can not remove moderators with this method
throw new InvalidArgumentException('moderator');
}
$removals[] = [
'room' => $breakoutRoom,
'participant' => $participant,
];
}
}
}
foreach ($removals as $removal) {
$this->participantService->removeAttendee($removal['room'], $removal['participant'], AAttendeeRemovedEvent::REASON_REMOVED);
}
$map = [];
foreach ($participants as $participant) {
if (!isset($cleanedMap[$participant->getAttendee()->getId()])) {
continue;
}
$roomNumber = (int)$cleanedMap[$participant->getAttendee()->getId()];
$map[$roomNumber] ??= [];
$map[$roomNumber][] = $participant;
}
$this->addOthersToBreakoutRooms($breakoutRooms, $map);
return $breakoutRooms;
}
/**
* @param Room[] $rooms
* @param Participant[] $moderators
*/
public 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);
}
}
protected function createBreakoutRooms(Room $parent, int $amount): array {
// Safety caution cleaning up potential orphan rooms
$this->deleteBreakoutRooms($parent);
// TRANSLATORS Label for the breakout rooms, this is not a plural! The result will be "Room 1", "Room 2", "Room 3", ...
$label = $this->l->t('Room {number}');
$rooms = [];
for ($i = 1; $i <= $amount; $i++) {
$breakoutRoom = $this->roomService->createConversation(
$parent->getType(),
str_replace('{number}', (string)$i, $label),
null,
BreakoutRoom::PARENT_OBJECT_TYPE,
$parent->getToken()
);
$this->roomService->setLobby($breakoutRoom, Webinary::LOBBY_NON_MODERATORS, null, false, false);
$rooms[] = $breakoutRoom;
}
return $rooms;
}
public function removeBreakoutRooms(Room $parent): void {
$this->deleteBreakoutRooms($parent);
$this->roomService->setBreakoutRoomMode($parent, BreakoutRoom::MODE_NOT_CONFIGURED);
$this->roomService->setBreakoutRoomStatus($parent, BreakoutRoom::STATUS_STOPPED);
}
protected function deleteBreakoutRooms(Room $parent): void {
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());
foreach ($breakoutRooms as $breakoutRoom) {
$this->roomService->deleteRoom($breakoutRoom);
}
}
/**
* @param Room $parent
* @param Participant $participant
* @param string $message
* @return Room[]
*/
public function broadcastChatMessage(Room $parent, Participant $participant, string $message): array {
if ($parent->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
throw new InvalidArgumentException('mode');
}
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());
$attendeeType = $participant->getAttendee()->getActorType();
$attendeeId = $participant->getAttendee()->getActorId();
$creationDateTime = new \DateTime();
$shouldFlush = $this->notificationManager->defer();
try {
foreach ($breakoutRooms as $breakoutRoom) {
$breakoutParticipant = $this->participantService->getParticipantByActor($breakoutRoom, $attendeeType, $attendeeId);
$comment = $this->chatManager->sendMessage($breakoutRoom, $breakoutParticipant, $attendeeType, $attendeeId, $message, $creationDateTime, rateLimitGuestMentions: false);
$breakoutRoom->setLastMessage($comment);
}
} finally {
if ($shouldFlush) {
$this->notificationManager->flush();
}
}
return $breakoutRooms;
}
public function requestAssistance(Room $breakoutRoom): void {
$this->setAssistanceRequest($breakoutRoom, BreakoutRoom::STATUS_ASSISTANCE_REQUESTED);
}
public function resetRequestForAssistance(Room $breakoutRoom): void {
$this->setAssistanceRequest($breakoutRoom, BreakoutRoom::STATUS_ASSISTANCE_RESET);
}
protected function setAssistanceRequest(Room $breakoutRoom, int $status): void {
if ($breakoutRoom->getObjectType() !== BreakoutRoom::PARENT_OBJECT_TYPE) {
throw new InvalidArgumentException('room');
}
if ($breakoutRoom->getLobbyState() !== Webinary::LOBBY_NONE) {
throw new InvalidArgumentException('room');
}
if (!in_array($status, [
BreakoutRoom::STATUS_ASSISTANCE_RESET,
BreakoutRoom::STATUS_ASSISTANCE_REQUESTED,
], true)) {
throw new InvalidArgumentException('status');
}
$this->roomService->setBreakoutRoomStatus($breakoutRoom, $status);
$this->roomService->setLastActivity($breakoutRoom, $this->timeFactory->getDateTime());
}
/**
* @param Room $parent
* @return Room[]
*/
public function startBreakoutRooms(Room $parent): array {
if ($parent->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
throw new InvalidArgumentException('mode');
}
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken(), true);
foreach ($breakoutRooms as $breakoutRoom) {
$this->roomService->setLobby($breakoutRoom, Webinary::LOBBY_NONE, null);
}
$this->roomService->setBreakoutRoomStatus($parent, BreakoutRoom::STATUS_STARTED);
return $breakoutRooms;
}
/**
* @param Room $parent
* @return Room[]
*/
public function stopBreakoutRooms(Room $parent): array {
if ($parent->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
throw new InvalidArgumentException('mode');
}
$this->roomService->setBreakoutRoomStatus($parent, BreakoutRoom::STATUS_STOPPED);
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken(), true);
foreach ($breakoutRooms as $breakoutRoom) {
$this->roomService->setLobby($breakoutRoom, Webinary::LOBBY_NON_MODERATORS, null);
if ($breakoutRoom->getBreakoutRoomStatus() === BreakoutRoom::STATUS_ASSISTANCE_REQUESTED) {
$this->roomService->setBreakoutRoomStatus($breakoutRoom, BreakoutRoom::STATUS_ASSISTANCE_RESET);
}
}
return $breakoutRooms;
}
public function switchBreakoutRoom(Room $parent, Participant $participant, string $targetToken): Room {
if ($parent->getBreakoutRoomMode() !== BreakoutRoom::MODE_FREE) {
throw new InvalidArgumentException('mode');
}
if ($parent->getBreakoutRoomStatus() !== BreakoutRoom::STATUS_STARTED) {
throw new InvalidArgumentException('status');
}
if ($participant->hasModeratorPermissions()) {
// Moderators don't switch, they are part of all breakout rooms
throw new InvalidArgumentException('moderator');
}
$attendee = $participant->getAttendee();
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());
$target = null;
foreach ($breakoutRooms as $breakoutRoom) {
if ($targetToken === $breakoutRoom->getToken()) {
$target = $breakoutRoom;
break;
}
}
if ($target === null) {
throw new InvalidArgumentException('target');
}
foreach ($breakoutRooms as $breakoutRoom) {
try {
$removeParticipant = $this->participantService->getParticipantByActor(
$breakoutRoom,
$attendee->getActorType(),
$attendee->getActorId()
);
if ($targetToken !== $breakoutRoom->getToken()) {
// Remove from all other breakout rooms
$this->participantService->removeAttendee(
$breakoutRoom,
$removeParticipant,
AAttendeeRemovedEvent::REASON_LEFT
);
}
} catch (ParticipantNotFoundException $e) {
if ($targetToken === $breakoutRoom->getToken()) {
// Join the target breakout room
$this->participantService->addUsers(
$breakoutRoom,
[
[
'actorType' => $attendee->getActorType(),
'actorId' => $attendee->getActorId(),
'displayName' => $attendee->getDisplayName(),
'participantType' => $attendee->getParticipantType(),
]
]
);
}
}
}
return $target;
}
/**
* @param Room $parent
* @param Participant $participant
* @return Room[]
*/
public function getBreakoutRooms(Room $parent, Participant $participant): array {
if ($parent->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
throw new InvalidArgumentException('mode');
}
if (!$participant->hasModeratorPermissions() && $parent->getBreakoutRoomStatus() !== BreakoutRoom::STATUS_STARTED) {
throw new InvalidArgumentException('status');
}
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken(), true);
$returnAll = $participant->hasModeratorPermissions() || $parent->getBreakoutRoomMode() === BreakoutRoom::MODE_FREE;
if (!$returnAll) {
$rooms = [];
foreach ($breakoutRooms as $breakoutRoom) {
try {
$this->participantService->getParticipantByActor(
$breakoutRoom,
$participant->getAttendee()->getActorType(),
$participant->getAttendee()->getActorId()
);
$rooms[] = $breakoutRoom;
} catch (ParticipantNotFoundException $e) {
// Skip this room
}
}
return $rooms;
}
return $breakoutRooms;
}
/**
* @param Room $parent
* @param string $actorType
* @param string $actorId
* @param bool $throwOnModerator
* @return void
* @throws InvalidArgumentException When being used for a moderator
*/
public function removeAttendeeFromBreakoutRoom(Room $parent, string $actorType, string $actorId, bool $throwOnModerator = true): void {
$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());
foreach ($breakoutRooms as $breakoutRoom) {
try {
$participant = $this->participantService->getParticipantByActor(
$breakoutRoom,
$actorType,
$actorId
);
if ($throwOnModerator && $participant->hasModeratorPermissions()) {
throw new InvalidArgumentException('moderator');
}
$this->participantService->removeAttendee($breakoutRoom, $participant, AAttendeeRemovedEvent::REASON_REMOVED);
} catch (ParticipantNotFoundException $e) {
// Skip this room
}
}
}
}