*
* @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\Service;
use OCA\Circles\CirclesManager;
use OCA\Circles\Model\Circle;
use OCA\Circles\Model\Member;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Config;
use OCA\Talk\Events\AddParticipantsEvent;
use OCA\Talk\Events\AttendeesAddedEvent;
use OCA\Talk\Events\AttendeesRemovedEvent;
use OCA\Talk\Events\ChatEvent;
use OCA\Talk\Events\DuplicatedParticipantEvent;
use OCA\Talk\Events\EndCallForEveryoneEvent;
use OCA\Talk\Events\JoinRoomGuestEvent;
use OCA\Talk\Events\JoinRoomUserEvent;
use OCA\Talk\Events\ModifyEveryoneEvent;
use OCA\Talk\Events\ModifyParticipantEvent;
use OCA\Talk\Events\ParticipantEvent;
use OCA\Talk\Events\RemoveParticipantEvent;
use OCA\Talk\Events\RemoveUserEvent;
use OCA\Talk\Events\RoomEvent;
use OCA\Talk\Events\SendCallNotificationEvent;
use OCA\Talk\Events\SilentModifyParticipantEvent;
use OCA\Talk\Exceptions\ForbiddenException;
use OCA\Talk\Exceptions\InvalidPasswordException;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\UnauthorizedException;
use OCA\Talk\Federation\FederationManager;
use OCA\Talk\Federation\Notifications;
use OCA\Talk\Manager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\AttendeeMapper;
use OCA\Talk\Model\BreakoutRoom;
use OCA\Talk\Model\SelectHelper;
use OCA\Talk\Model\Session;
use OCA\Talk\Model\SessionMapper;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCA\Talk\Webinary;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Comments\IComment;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Security\ISecureRandom;
use OCP\Server;
class ParticipantService {
protected array $userCache;
protected array $sessionCache;
public function __construct(
protected IConfig $serverConfig,
protected Config $talkConfig,
protected AttendeeMapper $attendeeMapper,
protected SessionMapper $sessionMapper,
protected SessionService $sessionService,
private ISecureRandom $secureRandom,
protected IDBConnection $connection,
private IEventDispatcher $dispatcher,
private IUserManager $userManager,
private IGroupManager $groupManager,
private MembershipService $membershipService,
private Notifications $notifications,
private ITimeFactory $timeFactory,
private ICacheFactory $cacheFactory,
) {
}
public function updateParticipantType(Room $room, Participant $participant, int $participantType): void {
$attendee = $participant->getAttendee();
if ($attendee->getActorType() === Attendee::ACTOR_GROUPS) {
// Can not promote/demote groups
return;
}
$oldType = $attendee->getParticipantType();
if ($oldType === $participantType) {
return;
}
$event = new ModifyParticipantEvent($room, $participant, 'type', $participantType, $oldType);
$this->dispatcher->dispatch(Room::EVENT_BEFORE_PARTICIPANT_TYPE_SET, $event);
$attendee->setParticipantType($participantType);
$promotedToModerator = in_array($participantType, [
Participant::OWNER,
Participant::MODERATOR,
], true);
$demotedFromModerator = in_array($oldType, [
Participant::OWNER,
Participant::MODERATOR,
], true);
if ($promotedToModerator) {
// Reset permissions on promotion
$attendee->setPermissions(Attendee::PERMISSIONS_DEFAULT);
}
$this->attendeeMapper->update($attendee);
// XOR so we don't move the participant in and out when they are changed from moderator to owner or vice-versa
if (($promotedToModerator xor $demotedFromModerator) && $room->getBreakoutRoomMode() !== BreakoutRoom::MODE_NOT_CONFIGURED) {
/** @var Manager $manager */
$manager = Server::get(Manager::class);
$breakoutRooms = $manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $room->getToken());
foreach ($breakoutRooms as $breakoutRoom) {
try {
$breakoutRoomParticipant = $this->getParticipantByActor(
$breakoutRoom,
$attendee->getActorType(),
$attendee->getActorId()
);
if ($demotedFromModerator) {
// Remove participant from all breakout rooms
$this->removeAttendee($breakoutRoom, $breakoutRoomParticipant, Room::PARTICIPANT_REMOVED);
} elseif (!$breakoutRoomParticipant->hasModeratorPermissions()) {
if ($breakoutRoomParticipant->getAttendee()->getParticipantType() === Participant::USER
|| $breakoutRoomParticipant->getAttendee()->getParticipantType() === Participant::USER_SELF_JOINED) {
$this->updateParticipantType($breakoutRoom, $breakoutRoomParticipant, Participant::MODERATOR);
}
}
} catch (ParticipantNotFoundException $e) {
if ($promotedToModerator) {
// Add participant as a moderator when they were not in the room already
$this->addUsers($breakoutRoom, [
[
'actorType' => $attendee->getActorType(),
'actorId' => $attendee->getActorId(),
'displayName' => $attendee->getDisplayName(),
'participantType' => $attendee->getParticipantType(),
],
]);
}
}
}
}
$this->dispatcher->dispatch(Room::EVENT_AFTER_PARTICIPANT_TYPE_SET, $event);
}
/**
* @throws Exception
* @throws ForbiddenException
*/
public function updatePermissions(Room $room, Participant $participant, string $method, int $newPermissions): bool {
if ($room->getType() === Room::TYPE_ONE_TO_ONE || $room->getType() === Room::TYPE_ONE_TO_ONE_FORMER) {
return false;
}
if ($participant->hasModeratorPermissions()) {
throw new ForbiddenException();
}
$attendee = $participant->getAttendee();
if ($attendee->getActorType() === Attendee::ACTOR_GROUPS || $attendee->getActorType() === Attendee::ACTOR_CIRCLES) {
// Can not set publishing permissions for those actor types
return false;
}
$oldPermissions = $participant->getPermissions();
if ($method === Attendee::PERMISSIONS_MODIFY_SET) {
if ($newPermissions !== Attendee::PERMISSIONS_DEFAULT) {
// Make sure the custom flag is set when not setting to default permissions
$newPermissions |= Attendee::PERMISSIONS_CUSTOM;
}
} elseif ($method === Attendee::PERMISSIONS_MODIFY_ADD) {
$newPermissions = $oldPermissions | $newPermissions;
} elseif ($method === Attendee::PERMISSIONS_MODIFY_REMOVE) {
$newPermissions = $oldPermissions & ~$newPermissions;
} else {
return false;
}
$event = new ModifyParticipantEvent($room, $participant, 'permissions', $newPermissions, $oldPermissions);
$this->dispatcher->dispatch(Room::EVENT_BEFORE_PARTICIPANT_PERMISSIONS_SET, $event);
$attendee->setPermissions($newPermissions);
if ($attendee->getParticipantType() === Participant::USER_SELF_JOINED) {
$attendee->setParticipantType(Participant::USER);
}
$this->attendeeMapper->update($attendee);
$this->dispatcher->dispatch(Room::EVENT_AFTER_PARTICIPANT_PERMISSIONS_SET, $event);
return true;
}
public function updateAllPermissions(Room $room, string $method, int $newState): void {
$this->attendeeMapper->modifyPermissions($room->getId(), $method, $newState);
}
public function updateLastReadMessage(Participant $participant, int $lastReadMessage): void {
$attendee = $participant->getAttendee();
$attendee->setLastReadMessage($lastReadMessage);
$this->attendeeMapper->update($attendee);
}
public function updateFavoriteStatus(Participant $participant, bool $isFavorite): void {
$attendee = $participant->getAttendee();
$attendee->setFavorite($isFavorite);
$this->attendeeMapper->update($attendee);
}
/**
* @param Participant $participant
* @param int $level
* @throws \InvalidArgumentException When the notification level is invalid
*/
public function updateNotificationLevel(Participant $participant, int $level): void {
if (!\in_array($level, [
Participant::NOTIFY_ALWAYS,
Participant::NOTIFY_MENTION,
Participant::NOTIFY_NEVER
], true)) {
throw new \InvalidArgumentException('Invalid notification level');
}
$attendee = $participant->getAttendee();
$attendee->setNotificationLevel($level);
$this->attendeeMapper->update($attendee);
}
/**
* @param Participant $participant
* @param int $level
*/
public function updateNotificationCalls(Participant $participant, int $level): void {
if (!\in_array($level, [
Participant::NOTIFY_CALLS_OFF,
Participant::NOTIFY_CALLS_ON,
], true)) {
throw new \InvalidArgumentException('Invalid notification level');
}
$attendee = $participant->getAttendee();
$attendee->setNotificationCalls($level);
$this->attendeeMapper->update($attendee);
}
/**
* @param RoomService $roomService
* @param Room $room
* @param IUser $user
* @param string $password
* @param bool $passedPasswordProtection
* @return Participant
* @throws InvalidPasswordException
* @throws UnauthorizedException
*/
public function joinRoom(RoomService $roomService, Room $room, IUser $user, string $password, bool $passedPasswordProtection = false): Participant {
$event = new JoinRoomUserEvent($room, $user, $password, $passedPasswordProtection);
$this->dispatcher->dispatch(Room::EVENT_BEFORE_ROOM_CONNECT, $event);
if ($event->getCancelJoin() === true) {
$this->removeUser($room, $user, Room::PARTICIPANT_LEFT);
throw new UnauthorizedException('Participant is not allowed to join');
}
try {
$attendee = $this->attendeeMapper->findByActor($room->getId(), Attendee::ACTOR_USERS, $user->getUID());
} catch (DoesNotExistException $e) {
// queried here to avoid loop deps
$manager = Server::get(Manager::class);
$isListableByUser = $manager->isRoomListableByUser($room, $user->getUID());
if (!$isListableByUser && !$event->getPassedPasswordProtection() && !$roomService->verifyPassword($room, $password)['result']) {
throw new InvalidPasswordException('Provided password is invalid');
}
// User joining a group or public call through listing
if (($room->getType() === Room::TYPE_GROUP || $room->getType() === Room::TYPE_PUBLIC) && $isListableByUser) {
$this->addUsers($room, [[
'actorType' => Attendee::ACTOR_USERS,
'actorId' => $user->getUID(),
'displayName' => $user->getDisplayName(),
// need to use "USER" here, because "USER_SELF_JOINED" only works for public calls
'participantType' => Participant::USER,
]], $user);
} elseif ($room->getType() === Room::TYPE_PUBLIC) {
// User joining a public room, without being invited
$this->addUsers($room, [[
'actorType' => Attendee::ACTOR_USERS,
'actorId' => $user->getUID(),
'displayName' => $user->getDisplayName(),
'participantType' => Participant::USER_SELF_JOINED,
]], $user);
} else {
// shouldn't happen unless some code called joinRoom without previous checks
throw new UnauthorizedException('Participant is not allowed to join');
}
$attendee = $this->attendeeMapper->findByActor($room->getId(), Attendee::ACTOR_USERS, $user->getUID());
}
$session = $this->sessionService->createSessionForAttendee($attendee);
$this->dispatcher->dispatch(Room::EVENT_AFTER_ROOM_CONNECT, $event);
return new Participant($room, $attendee, $session);
}
/**
* @param RoomService $roomService
* @param Room $room
* @param string $password
* @param bool $passedPasswordProtection
* @param ?Participant $previousParticipant
* @return Participant
* @throws InvalidPasswordException
* @throws UnauthorizedException
*/
public function joinRoomAsNewGuest(RoomService $roomService, Room $room, string $password, bool $passedPasswordProtection = false, ?Participant $previousParticipant = null): Participant {
$event = new JoinRoomGuestEvent($room, $password, $passedPasswordProtection);
$this->dispatcher->dispatch(Room::EVENT_BEFORE_GUEST_CONNECT, $event);
if ($event->getCancelJoin()) {
throw new UnauthorizedException('Participant is not allowed to join');
}
if (!$event->getPassedPasswordProtection() && !$roomService->verifyPassword($room, $password)['result']) {
throw new InvalidPasswordException();
}
$lastMessage = 0;
if ($room->getLastMessage() instanceof IComment) {
$lastMessage = (int) $room->getLastMessage()->getId();
}
if ($previousParticipant instanceof Participant) {
$attendee = $previousParticipant->getAttendee();
} else {
$randomActorId = $this->secureRandom->generate(255);
$attendee = new Attendee();
$attendee->setRoomId($room->getId());
$attendee->setActorType(Attendee::ACTOR_GUESTS);
$attendee->setActorId($randomActorId);
$attendee->setParticipantType(Participant::GUEST);
$attendee->setPermissions(Attendee::PERMISSIONS_DEFAULT);
$attendee->setLastReadMessage($lastMessage);
$this->attendeeMapper->insert($attendee);
$attendeeEvent = new AttendeesAddedEvent($room, [$attendee]);
$this->dispatcher->dispatchTyped($attendeeEvent);
}
$session = $this->sessionService->createSessionForAttendee($attendee);
if (!$previousParticipant instanceof Participant) {
// Update the random guest id
$attendee->setActorId(sha1($session->getSessionId()));
$this->attendeeMapper->update($attendee);
}
$this->dispatcher->dispatch(Room::EVENT_AFTER_GUEST_CONNECT, $event);
return new Participant($room, $attendee, $session);
}
/**
* @param Room $room
* @param array $participants
* @param IUser|null $addedBy User that is attempting to add these users (must be set for federated users to be added)
* @throws \Exception thrown if $addedBy is not set when adding a federated user
*/
public function addUsers(Room $room, array $participants, ?IUser $addedBy = null): void {
if (empty($participants)) {
return;
}
$event = new AddParticipantsEvent($room, $participants, true);
$this->dispatcher->dispatch(Room::EVENT_BEFORE_USERS_ADD, $event);
$lastMessage = 0;
if ($room->getLastMessage() instanceof IComment) {
$lastMessage = (int) $room->getLastMessage()->getId();
}
$attendees = [];
foreach ($participants as $participant) {
$readPrivacy = Participant::PRIVACY_PUBLIC;
if ($participant['actorType'] === Attendee::ACTOR_USERS) {
$readPrivacy = $this->talkConfig->getUserReadPrivacy($participant['actorId']);
} elseif ($participant['actorType'] === Attendee::ACTOR_FEDERATED_USERS) {
if ($addedBy === null) {
throw new \Exception('$addedBy must be set to add a federated user');
}
$participant['accessToken'] = $this->secureRandom->generate(
FederationManager::TOKEN_LENGTH,
ISecureRandom::CHAR_HUMAN_READABLE
);
}
$attendee = new Attendee();
$attendee->setRoomId($room->getId());
$attendee->setActorType($participant['actorType']);
$attendee->setActorId($participant['actorId']);
if (isset($participant['displayName'])) {
$attendee->setDisplayName($participant['displayName']);
}
if (isset($participant['accessToken'])) {
$attendee->setAccessToken($participant['accessToken']);
}
if (isset($participant['remoteId'])) {
$attendee->setRemoteId($participant['remoteId']);
}
$attendee->setParticipantType($participant['participantType'] ?? Participant::USER);
$attendee->setPermissions(Attendee::PERMISSIONS_DEFAULT);
$attendee->setLastReadMessage($lastMessage);
$attendee->setReadPrivacy($readPrivacy);
try {
$entity = $this->attendeeMapper->insert($attendee);
$attendees[] = $attendee;
if ($attendee->getActorType() === Attendee::ACTOR_FEDERATED_USERS) {
$this->notifications->sendRemoteShare((string) $entity->getId(), $participant['accessToken'], $participant['actorId'], $addedBy->getDisplayName(), $addedBy->getCloudId(), 'user', $room, $this->getHighestPermissionAttendee($room));
}
} catch (Exception $e) {
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
throw $e;
}
}
}
if (!empty($attendees)) {
$attendeeEvent = new AttendeesAddedEvent($room, $attendees);
$this->dispatcher->dispatchTyped($attendeeEvent);
$this->dispatcher->dispatch(Room::EVENT_AFTER_USERS_ADD, $event);
$lastMessage = $event->getLastMessage();
if ($lastMessage instanceof IComment) {
$this->updateRoomLastMessage($room, $lastMessage);
}
}
}
protected function updateRoomLastMessage(Room $room, IComment $message): void {
/** @var RoomService $roomService */
$roomService = Server::get(RoomService::class);
$roomService->setLastMessage($room, $message);
$lastMessageCache = $this->cacheFactory->createDistributed('talk/lastmsgid');
$lastMessageCache->remove($room->getToken());
$unreadCountCache = $this->cacheFactory->createDistributed('talk/unreadcount');
$unreadCountCache->clear($room->getId() . '-');
$event = new ChatEvent($room, $message);
$this->dispatcher->dispatch(ChatManager::EVENT_AFTER_MULTIPLE_SYSTEM_MESSAGE_SEND, $event);
}
public function getHighestPermissionAttendee(Room $room): ?Attendee {
try {
$roomOwners = $this->attendeeMapper->getActorsByParticipantTypes($room->getId(), [Participant::OWNER]);
if (!empty($roomOwners)) {
foreach ($roomOwners as $owner) {
if ($owner->getActorType() === Attendee::ACTOR_USERS) {
return $owner;
}
}
}
$roomModerators = $this->attendeeMapper->getActorsByParticipantTypes($room->getId(), [Participant::MODERATOR]);
if (!empty($roomOwners)) {
foreach ($roomModerators as $moderator) {
if ($moderator->getActorType() === Attendee::ACTOR_USERS) {
return $moderator;
}
}
}
} catch (Exception $e) {
}
return null;
}
/**
* @param Room $room
* @param IGroup $group
* @param Participant[] $existingParticipants
*/
public function addGroup(Room $room, IGroup $group, array $existingParticipants = []): void {
$usersInGroup = $group->getUsers();
if (empty($existingParticipants)) {
$existingParticipants = $this->getParticipantsForRoom($room);
}
$participantsByUserId = [];
foreach ($existingParticipants as $participant) {
if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
$participantsByUserId[$participant->getAttendee()->getActorId()] = $participant;
}
}
$newParticipants = [];
foreach ($usersInGroup as $user) {
$existingParticipant = $participantsByUserId[$user->getUID()] ?? null;
if ($existingParticipant instanceof Participant) {
if ($existingParticipant->getAttendee()->getParticipantType() === Participant::USER_SELF_JOINED) {
$this->updateParticipantType($room, $existingParticipant, Participant::USER);
}
// Participant is already in the conversation, so skip them.
continue;
}
$newParticipants[] = [
'actorType' => Attendee::ACTOR_USERS,
'actorId' => $user->getUID(),
'displayName' => $user->getDisplayName(),
];
}
try {
$this->attendeeMapper->findByActor($room->getId(), Attendee::ACTOR_GROUPS, $group->getGID());
} catch (DoesNotExistException $e) {
$attendee = new Attendee();
$attendee->setRoomId($room->getId());
$attendee->setActorType(Attendee::ACTOR_GROUPS);
$attendee->setActorId($group->getGID());
$attendee->setDisplayName($group->getDisplayName());
$attendee->setParticipantType(Participant::USER);
$attendee->setPermissions(Attendee::PERMISSIONS_DEFAULT);
$attendee->setReadPrivacy(Participant::PRIVACY_PRIVATE);
$this->attendeeMapper->insert($attendee);
$attendeeEvent = new AttendeesAddedEvent($room, [$attendee]);
$this->dispatcher->dispatchTyped($attendeeEvent);
}
$this->addUsers($room, $newParticipants);
}
/**
* @param string $circleId
* @param string $userId
* @return Circle
* @throws ParticipantNotFoundException
*/
public function getCircle(string $circleId, string $userId): Circle {
try {
$circlesManager = Server::get(CirclesManager::class);
$federatedUser = $circlesManager->getFederatedUser($userId, Member::TYPE_USER);
$federatedUser->getLink($circleId);
} catch (\Exception $e) {
throw new ParticipantNotFoundException('Circle not found or not a member');
}
$circlesManager->startSession($federatedUser);
try {
$circle = $circlesManager->getCircle($circleId);
$circlesManager->stopSession();
return $circle;
} catch (\Exception $e) {
}
$circlesManager->stopSession();
throw new ParticipantNotFoundException('Circle not found or not a member');
}
/**
* @param Room $room
* @param Circle $circle
* @param Participant[] $existingParticipants
*/
public function addCircle(Room $room, Circle $circle, array $existingParticipants = []): void {
$membersInCircle = $circle->getInheritedMembers();
if (empty($existingParticipants)) {
$existingParticipants = $this->getParticipantsForRoom($room);
}
$participantsByUserId = [];
foreach ($existingParticipants as $participant) {
if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
$participantsByUserId[$participant->getAttendee()->getActorId()] = $participant;
}
}
$newParticipants = [];
foreach ($membersInCircle as $member) {
/** @var Member $member */
if ($member->getUserType() !== Member::TYPE_USER || $member->getUserId() === '') {
// Not a user?
continue;
}
if ($member->getStatus() !== Member::STATUS_INVITED && $member->getStatus() !== Member::STATUS_MEMBER) {
// Only allow invited and regular members
continue;
}
$user = $this->userManager->get($member->getUserId());
if (!$user instanceof IUser) {
continue;
}
$existingParticipant = $participantsByUserId[$user->getUID()] ?? null;
if ($existingParticipant instanceof Participant) {
if ($existingParticipant->getAttendee()->getParticipantType() === Participant::USER_SELF_JOINED) {
$this->updateParticipantType($room, $existingParticipant, Participant::USER);
}
// Participant is already in the conversation, so skip them.
continue;
}
$newParticipants[] = [
'actorType' => Attendee::ACTOR_USERS,
'actorId' => $user->getUID(),
'displayName' => $user->getDisplayName(),
];
}
try {
$this->attendeeMapper->findByActor($room->getId(), Attendee::ACTOR_CIRCLES, $circle->getSingleId());
} catch (DoesNotExistException $e) {
$attendee = new Attendee();
$attendee->setRoomId($room->getId());
$attendee->setActorType(Attendee::ACTOR_CIRCLES);
$attendee->setActorId($circle->getSingleId());
$attendee->setDisplayName($circle->getDisplayName());
$attendee->setParticipantType(Participant::USER);
$attendee->setPermissions(Attendee::PERMISSIONS_DEFAULT);
$attendee->setReadPrivacy(Participant::PRIVACY_PRIVATE);
$this->attendeeMapper->insert($attendee);
$attendeeEvent = new AttendeesAddedEvent($room, [$attendee]);
$this->dispatcher->dispatchTyped($attendeeEvent);
}
$this->addUsers($room, $newParticipants);
}
/**
* @param Room $room
* @param string $email
* @return Participant
*/
public function inviteEmailAddress(Room $room, string $email): Participant {
$lastMessage = 0;
if ($room->getLastMessage() instanceof IComment) {
$lastMessage = (int) $room->getLastMessage()->getId();
}
$attendee = new Attendee();
$attendee->setRoomId($room->getId());
$attendee->setActorType(Attendee::ACTOR_EMAILS);
$attendee->setActorId($email);
if ($room->getSIPEnabled() !== Webinary::SIP_DISABLED
&& $this->talkConfig->isSIPConfigured()) {
$attendee->setPin($this->generatePin());
}
$attendee->setParticipantType(Participant::GUEST);
$attendee->setLastReadMessage($lastMessage);
$this->attendeeMapper->insert($attendee);
// FIXME handle duplicate invites gracefully
$attendeeEvent = new AttendeesAddedEvent($room, [$attendee]);
$this->dispatcher->dispatchTyped($attendeeEvent);
return new Participant($room, $attendee, null);
}
public function generatePinForParticipant(Room $room, Participant $participant): void {
$attendee = $participant->getAttendee();
if ($room->getSIPEnabled() !== Webinary::SIP_DISABLED
&& $this->talkConfig->isSIPConfigured()
&& ($attendee->getActorType() === Attendee::ACTOR_USERS || $attendee->getActorType() === Attendee::ACTOR_EMAILS)
&& !$attendee->getPin()) {
$attendee->setPin($this->generatePin());
$this->attendeeMapper->update($attendee);
}
}
public function ensureOneToOneRoomIsFilled(Room $room): void {
if ($room->getType() !== Room::TYPE_ONE_TO_ONE) {
return;
}
$users = json_decode($room->getName(), true);
$participants = $this->getParticipantUserIds($room);
$missingUsers = array_diff($users, $participants);
foreach ($missingUsers as $userId) {
$userDisplayName = $this->userManager->getDisplayName($userId);
if ($userDisplayName !== null) {
$this->addUsers($room, [[
'actorType' => Attendee::ACTOR_USERS,
'actorId' => $userId,
'displayName' => $userDisplayName,
'participantType' => Participant::OWNER,
]]);
}
}
}
public function leaveRoomAsSession(Room $room, Participant $participant, bool $duplicatedParticipant = false): void {
if ($duplicatedParticipant) {
$event = new DuplicatedParticipantEvent($room, $participant);
} else {
$event = new ParticipantEvent($room, $participant);
}
$this->dispatcher->dispatch(Room::EVENT_BEFORE_ROOM_DISCONNECT, $event);
$session = $participant->getSession();
if ($session instanceof Session) {
$isInCall = $session->getInCall() !== Participant::FLAG_DISCONNECTED;
if ($isInCall) {
$this->changeInCall($room, $participant, Participant::FLAG_DISCONNECTED);
}
$this->sessionMapper->delete($session);
} else {
$this->sessionMapper->deleteByAttendeeId($participant->getAttendee()->getId());
}
$this->dispatcher->dispatch(Room::EVENT_AFTER_ROOM_DISCONNECT, $event);
if ($participant->getAttendee()->getParticipantType() === Participant::USER_SELF_JOINED
&& empty($this->sessionMapper->findByAttendeeId($participant->getAttendee()->getId()))) {
$user = $this->userManager->get($participant->getAttendee()->getActorId());
$this->removeUser($room, $user, Room::PARTICIPANT_LEFT);
}
}
public function removeAttendee(Room $room, Participant $participant, string $reason, bool $attendeeEventIsTriggeredAlready = false): void {
$isUser = $participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS;
$sessions = $this->sessionService->getAllSessionsForAttendee($participant->getAttendee());
if ($room->getBreakoutRoomMode() !== BreakoutRoom::MODE_NOT_CONFIGURED) {
/** @var BreakoutRoomService $breakoutRoomService */
$breakoutRoomService = Server::get(BreakoutRoomService::class);
$breakoutRoomService->removeAttendeeFromBreakoutRoom(
$room,
$participant->getAttendee()->getActorType(),
$participant->getAttendee()->getActorId(),
false
);
}
if ($isUser) {
$user = $this->userManager->get($participant->getAttendee()->getActorId());
$event = new RemoveUserEvent($room, $participant, $user, $reason, $sessions);
$this->dispatcher->dispatch(Room::EVENT_BEFORE_USER_REMOVE, $event);
} else {
$event = new RemoveParticipantEvent($room, $participant, $reason, $sessions);
$this->dispatcher->dispatch(Room::EVENT_BEFORE_PARTICIPANT_REMOVE, $event);
}
$this->sessionMapper->deleteByAttendeeId($participant->getAttendee()->getId());
$this->attendeeMapper->delete($participant->getAttendee());
if (!$attendeeEventIsTriggeredAlready) {
$attendeeEvent = new AttendeesRemovedEvent($room, [$participant->getAttendee()]);
$this->dispatcher->dispatchTyped($attendeeEvent);
}
if ($isUser) {
$this->dispatcher->dispatch(Room::EVENT_AFTER_USER_REMOVE, $event);
} else {
$this->dispatcher->dispatch(Room::EVENT_AFTER_PARTICIPANT_REMOVE, $event);
}
if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_GROUPS) {
$this->removeGroupMembers($room, $participant, $reason);
} elseif ($participant->getAttendee()->getActorType() === Attendee::ACTOR_CIRCLES) {
$this->removeCircleMembers($room, $participant, $reason);
}
}
/**
* @return Attendee[]
*/
public function getActorsByType(Room $room, string $actorType): array {
return $this->attendeeMapper->getActorsByType($room->getId(), $actorType);
}
public function removeGroupMembers(Room $room, Participant $removedGroupParticipant, string $reason): void {
$removedGroup = $this->groupManager->get($removedGroupParticipant->getAttendee()->getActorId());
if (!$removedGroup instanceof IGroup) {
return;
}
$users = $this->membershipService->getUsersWithoutOtherMemberships($room, $removedGroup->getUsers());
$attendees = [];
foreach ($users as $user) {
try {
$participant = $this->getParticipant($room, $user->getUID());
$participantType = $participant->getAttendee()->getParticipantType();
$attendees[] = $participant->getAttendee();
if ($participantType === Participant::USER) {
// Only remove normal users, not moderators/admins
$this->removeAttendee($room, $participant, $reason, true);
}
} catch (ParticipantNotFoundException $e) {
}
}
$attendeeEvent = new AttendeesRemovedEvent($room, $attendees);
$this->dispatcher->dispatchTyped($attendeeEvent);
}
public function removeCircleMembers(Room $room, Participant $removedCircleParticipant, string $reason): void {
try {
$circlesManager = Server::get(CirclesManager::class);
$circlesManager->startSuperSession();
$circle = $circlesManager->getCircle($removedCircleParticipant->getAttendee()->getActorId());
$circlesManager->stopSession();
} catch (\Exception $e) {
// Circles not enabled
return;
}
$circlesManager->startSuperSession();
try {
$circle = $circlesManager->getCircle($removedCircleParticipant->getAttendee()->getActorId());
$circlesManager->stopSession();
} catch (\Exception $e) {
$circlesManager->stopSession();
return;
}
$membersInCircle = $circle->getInheritedMembers();
$users = [];
foreach ($membersInCircle as $member) {
/** @var Member $member */
if ($member->getUserType() !== Member::TYPE_USER || $member->getUserId() === '') {
// Not a user?
continue;
}
if ($member->getStatus() !== Member::STATUS_INVITED && $member->getStatus() !== Member::STATUS_MEMBER) {
// Only allow invited and regular members
continue;
}
$users[] = $this->userManager->get($member->getUserId());
}
$users = array_filter($users);
if (empty($users)) {
return;
}
$users = $this->membershipService->getUsersWithoutOtherMemberships($room, $users);
$attendees = [];
foreach ($users as $user) {
try {
$participant = $this->getParticipant($room, $user->getUID());
$participantType = $participant->getAttendee()->getParticipantType();
$attendees[] = $participant->getAttendee();
if ($participantType === Participant::USER) {
// Only remove normal users, not moderators/admins
$this->removeAttendee($room, $participant, $reason, true);
}
} catch (ParticipantNotFoundException $e) {
}
}
$attendeeEvent = new AttendeesRemovedEvent($room, $attendees);
$this->dispatcher->dispatchTyped($attendeeEvent);
}
public function removeUser(Room $room, IUser $user, string $reason): void {
try {
$participant = $this->getParticipant($room, $user->getUID(), false);
} catch (ParticipantNotFoundException $e) {
return;
}
$attendee = $participant->getAttendee();
$sessions = $this->sessionService->getAllSessionsForAttendee($attendee);
if ($reason !== Room::PARTICIPANT_REMOVED_ALL && $room->getBreakoutRoomMode() !== BreakoutRoom::MODE_NOT_CONFIGURED) {
/** @var BreakoutRoomService $breakoutRoomService */
$breakoutRoomService = Server::get(BreakoutRoomService::class);
$breakoutRoomService->removeAttendeeFromBreakoutRoom(
$room,
$attendee->getActorType(),
$attendee->getActorId(),
false
);
} elseif ($reason === Room::PARTICIPANT_REMOVED_ALL) {
$reason = Room::PARTICIPANT_REMOVED;
}
$event = new RemoveUserEvent($room, $participant, $user, $reason, $sessions);
$this->dispatcher->dispatch(Room::EVENT_BEFORE_USER_REMOVE, $event);
foreach ($sessions as $session) {
$this->sessionMapper->delete($session);
}
$this->attendeeMapper->delete($attendee);
$attendeeEvent = new AttendeesRemovedEvent($room, [$attendee]);
$this->dispatcher->dispatchTyped($attendeeEvent);
$this->dispatcher->dispatch(Room::EVENT_AFTER_USER_REMOVE, $event);
}
public function cleanGuestParticipants(Room $room): void {
$event = new RoomEvent($room);
$this->dispatcher->dispatch(Room::EVENT_BEFORE_GUESTS_CLEAN, $event);
$query = $this->connection->getQueryBuilder();
$query->selectAlias('s.id', 's_id')
->from('talk_sessions', 's')
->leftJoin('s', 'talk_attendees', 'a', $query->expr()->eq('s.attendee_id', 'a.id'))
->where($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('a.actor_type', $query->createNamedParameter(Attendee::ACTOR_GUESTS)))
->andWhere($query->expr()->lte('s.last_ping', $query->createNamedParameter($this->timeFactory->getTime() - Session::SESSION_TIMEOUT_KILL, IQueryBuilder::PARAM_INT)));
$sessionTableIds = [];
$result = $query->executeQuery();
while ($row = $result->fetch()) {
$sessionTableIds[] = (int) $row['s_id'];
}
$result->closeCursor();
$this->sessionService->deleteSessionsById($sessionTableIds);
$query = $this->connection->getQueryBuilder();
$helper = new SelectHelper();
$helper->selectAttendeesTable($query);
$query->from('talk_attendees', 'a')
->leftJoin('a', 'talk_sessions', 's', $query->expr()->eq('s.attendee_id', 'a.id'))
->where($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('a.actor_type', $query->createNamedParameter(Attendee::ACTOR_GUESTS)))
->andWhere($query->expr()->isNull('s.id'));
$attendeeIds = [];
$attendees = [];
$result = $query->executeQuery();
while ($row = $result->fetch()) {
if ($row['display_name'] !== '' && $row['display_name'] !== null) {
// Keep guests with a non-empty display name, so we can still
// render the guest display name on chat messages.
continue;
}
if ((int) $row['participant_type'] !== Participant::GUEST
|| ((int) $row['permissions'] !== Attendee::PERMISSIONS_DEFAULT
&& (int) $row['permissions'] !== Attendee::PERMISSIONS_CUSTOM)) {
// Keep guests with non-default permissions in case they just reconnect
continue;
}
$attendeeIds[] = (int) $row['a_id'];
$attendees[] = $this->attendeeMapper->createAttendeeFromRow($row);
}
$result->closeCursor();
if (empty($attendeeIds)) {
return;
}
$this->attendeeMapper->deleteByIds($attendeeIds);
$attendeeEvent = new AttendeesRemovedEvent($room, $attendees);
$this->dispatcher->dispatchTyped($attendeeEvent);
$this->dispatcher->dispatch(Room::EVENT_AFTER_GUESTS_CLEAN, $event);
}
public function endCallForEveryone(Room $room, Participant $moderator): void {
$event = new EndCallForEveryoneEvent($room, $moderator);
$this->dispatcher->dispatch(Room::EVENT_BEFORE_END_CALL_FOR_EVERYONE, $event);
$participants = $this->getParticipantsInCall($room);
$changedSessionIds = [];
$changedUserIds = [];
// kick out all participants out of the call
foreach ($participants as $participant) {
$changedSessionIds[] = $participant->getSession()->getSessionId();
if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
$changedUserIds[] = $participant->getAttendee()->getActorId();
}
$this->changeInCall($room, $participant, Participant::FLAG_DISCONNECTED, true);
}
$this->sessionMapper->resetInCallByIds($changedSessionIds);
$event->setSessionIds($changedSessionIds);
$event->setUserIds($changedUserIds);
$this->dispatcher->dispatch(Room::EVENT_AFTER_END_CALL_FOR_EVERYONE, $event);
}
public function changeInCall(Room $room, Participant $participant, int $flags, bool $endCallForEveryone = false, bool $silent = false): bool {
if ($room->getType() === Room::TYPE_CHANGELOG || $room->getType() === Room::TYPE_NOTE_TO_SELF) {
return false;
}
$session = $participant->getSession();
if (!$session instanceof Session) {
return false;
}
$permissions = $participant->getPermissions();
if (!($permissions & Attendee::PERMISSIONS_PUBLISH_AUDIO)) {
$flags &= ~Participant::FLAG_WITH_AUDIO;
}
if (!($permissions & Attendee::PERMISSIONS_PUBLISH_VIDEO)) {
$flags &= ~Participant::FLAG_WITH_VIDEO;
}
if ($flags !== Participant::FLAG_DISCONNECTED) {
if ($silent) {
$event = new SilentModifyParticipantEvent($room, $participant, 'inCall', $flags, $session->getInCall());
} else {
$event = new ModifyParticipantEvent($room, $participant, 'inCall', $flags, $session->getInCall());
}
$this->dispatcher->dispatch(Room::EVENT_BEFORE_SESSION_JOIN_CALL, $event);
} else {
if ($endCallForEveryone) {
$event = new ModifyEveryoneEvent($room, $participant, 'inCall', $flags, $session->getInCall());
} else {
$event = new ModifyParticipantEvent($room, $participant, 'inCall', $flags, $session->getInCall());
}
$this->dispatcher->dispatch(Room::EVENT_BEFORE_SESSION_LEAVE_CALL, $event);
}
$session->setInCall($flags);
if (!$endCallForEveryone) {
$this->sessionMapper->update($session);
}
if ($flags !== Participant::FLAG_DISCONNECTED) {
$attendee = $participant->getAttendee();
$attendee->setLastJoinedCall($this->timeFactory->getTime());
$this->attendeeMapper->update($attendee);
}
if ($flags !== Participant::FLAG_DISCONNECTED) {
$this->dispatcher->dispatch(Room::EVENT_AFTER_SESSION_JOIN_CALL, $event);
} else {
$this->dispatcher->dispatch(Room::EVENT_AFTER_SESSION_LEAVE_CALL, $event);
}
return true;
}
public function sendCallNotificationForAttendee(Room $room, Participant $currentParticipant, int $targetAttendeeId): bool {
$attendee = $this->attendeeMapper->getById($targetAttendeeId);
if ($attendee->getActorType() !== Attendee::ACTOR_USERS) {
return false;
}
$sessions = $this->sessionMapper->findByAttendeeId($targetAttendeeId);
foreach ($sessions as $session) {
if ($session->getInCall() !== Participant::FLAG_DISCONNECTED) {
return false;
}
}
$this->dispatcher->dispatchTyped(new SendCallNotificationEvent(
$room,
$currentParticipant,
new Participant($room, $attendee, null)
));
return true;
}
public function updateCallFlags(Room $room, Participant $participant, int $flags): void {
$session = $participant->getSession();
if (!$session instanceof Session) {
return;
}
if (!($session->getInCall() & Participant::FLAG_IN_CALL)) {
throw new \Exception('Participant not in call');
}
if (!($flags & Participant::FLAG_IN_CALL)) {
throw new \InvalidArgumentException('Invalid flags');
}
$permissions = $participant->getPermissions();
if (!($permissions & Attendee::PERMISSIONS_PUBLISH_AUDIO)) {
$flags &= ~Participant::FLAG_WITH_AUDIO;
}
if (!($permissions & Attendee::PERMISSIONS_PUBLISH_VIDEO)) {
$flags &= ~Participant::FLAG_WITH_VIDEO;
}
$event = new ModifyParticipantEvent($room, $participant, 'inCall', $flags, $session->getInCall());
$this->dispatcher->dispatch(Room::EVENT_BEFORE_SESSION_UPDATE_CALL_FLAGS, $event);
$session->setInCall($flags);
$this->sessionMapper->update($session);
$this->dispatcher->dispatch(Room::EVENT_AFTER_SESSION_UPDATE_CALL_FLAGS, $event);
}
/**
* @param Room $room
* @param string[] $userIds
* @param int $messageId
* @param string[] $usersDirectlyMentioned
*/
public function markUsersAsMentioned(Room $room, array $userIds, int $messageId, array $usersDirectlyMentioned): void {
$update = $this->connection->getQueryBuilder();
$update->update('talk_attendees')
->set('last_mention_message', $update->createNamedParameter($messageId, IQueryBuilder::PARAM_INT))
->where($update->expr()->eq('room_id', $update->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($update->expr()->eq('actor_type', $update->createNamedParameter(Attendee::ACTOR_USERS)))
->andWhere($update->expr()->in('actor_id', $update->createNamedParameter($userIds, IQueryBuilder::PARAM_STR_ARRAY)));
$update->executeStatement();
if (!empty($usersDirectlyMentioned)) {
$update = $this->connection->getQueryBuilder();
$update->update('talk_attendees')
->set('last_mention_direct', $update->createNamedParameter($messageId, IQueryBuilder::PARAM_INT))
->where($update->expr()->eq('room_id', $update->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($update->expr()->eq('actor_type', $update->createNamedParameter(Attendee::ACTOR_USERS)))
->andWhere($update->expr()->in('actor_id', $update->createNamedParameter($usersDirectlyMentioned, IQueryBuilder::PARAM_STR_ARRAY)));
$update->executeStatement();
}
}
public function resetChatDetails(Room $room): void {
$update = $this->connection->getQueryBuilder();
$update->update('talk_attendees')
->set('last_read_message', $update->createNamedParameter(0, IQueryBuilder::PARAM_INT))
->set('last_mention_message', $update->createNamedParameter(0, IQueryBuilder::PARAM_INT))
->set('last_mention_direct', $update->createNamedParameter(0, IQueryBuilder::PARAM_INT))
->where($update->expr()->eq('room_id', $update->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT)));
$update->executeStatement();
}
public function updateReadPrivacyForActor(string $actorType, string $actorId, int $readPrivacy): void {
$update = $this->connection->getQueryBuilder();
$update->update('talk_attendees')
->set('read_privacy', $update->createNamedParameter($readPrivacy, IQueryBuilder::PARAM_INT))
->where($update->expr()->eq('actor_type', $update->createNamedParameter($actorType)))
->andWhere($update->expr()->eq('actor_id', $update->createNamedParameter($actorId)));
$update->executeStatement();
}
public function updateDisplayNameForActor(string $actorType, string $actorId, string $displayName): void {
$update = $this->connection->getQueryBuilder();
$update->update('talk_attendees')
->set('display_name', $update->createNamedParameter($displayName))
->where($update->expr()->eq('actor_type', $update->createNamedParameter($actorType)))
->andWhere($update->expr()->eq('actor_id', $update->createNamedParameter($actorId)));
$update->executeStatement();
}
public function getLastCommonReadChatMessage(Room $room): int {
$query = $this->connection->getQueryBuilder();
$query->selectAlias($query->func()->min('last_read_message'), 'last_common_read_message')
->from('talk_attendees')
->where($query->expr()->eq('actor_type', $query->createNamedParameter(Attendee::ACTOR_USERS)))
->andWhere($query->expr()->eq('room_id', $query->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('read_privacy', $query->createNamedParameter(Participant::PRIVACY_PUBLIC, IQueryBuilder::PARAM_INT)));
$result = $query->executeQuery();
$row = $result->fetch();
$result->closeCursor();
return (int) ($row['last_common_read_message'] ?? 0);
}
/**
* @param int[] $roomIds
* @return array A map of roomId => "last common read message id"
* @psalm-return array
*/
public function getLastCommonReadChatMessageForMultipleRooms(array $roomIds): array {
$query = $this->connection->getQueryBuilder();
$query->select('room_id')
->selectAlias($query->func()->min('last_read_message'), 'last_common_read_message')
->from('talk_attendees')
->where($query->expr()->eq('actor_type', $query->createNamedParameter(Attendee::ACTOR_USERS)))
->andWhere($query->expr()->in('room_id', $query->createNamedParameter($roomIds, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($query->expr()->eq('read_privacy', $query->createNamedParameter(Participant::PRIVACY_PUBLIC, IQueryBuilder::PARAM_INT)))
->groupBy('room_id');
$commonReads = array_fill_keys($roomIds, 0);
$result = $query->executeQuery();
while ($row = $result->fetch()) {
$commonReads[(int) $row['room_id']] = (int) $row['last_common_read_message'];
}
$result->closeCursor();
return $commonReads;
}
/**
* @param Room $room
* @return Participant[]
*/
public function getParticipantsForRoom(Room $room): array {
$query = $this->connection->getQueryBuilder();
$helper = new SelectHelper();
$helper->selectAttendeesTable($query);
$query->from('talk_attendees', 'a')
->where($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT)));
return $this->getParticipantsFromQuery($query, $room);
}
/**
* Get all sessions and attendees without a session for the room
*
* This will return multiple items for the same attendee if the attendee
* has multiple sessions in the room.
*
* @param Room $room
* @return Participant[]
*/
public function getSessionsAndParticipantsForRoom(Room $room): array {
$query = $this->connection->getQueryBuilder();
$helper = new SelectHelper();
$helper->selectAttendeesTable($query);
$helper->selectSessionsTable($query);
$query->from('talk_attendees', 'a')
->leftJoin(
'a', 'talk_sessions', 's',
$query->expr()->eq('s.attendee_id', 'a.id')
)
->where($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT)));
return $this->getParticipantsFromQuery($query, $room);
}
/**
* Get all sessions and attendees without a session for the room
*
* This will return multiple items for the same attendee if the attendee
* has multiple sessions in the room.
*
* @param Room[] $rooms
* @return Participant[]
*/
public function getSessionsAndParticipantsForRooms(array $rooms): array {
$roomIds = array_map(static fn (Room $room) => $room->getId(), $rooms);
$map = array_combine($roomIds, $rooms);
$query = $this->connection->getQueryBuilder();
$helper = new SelectHelper();
$helper->selectAttendeesTable($query);
$helper->selectSessionsTable($query);
$query->from('talk_attendees', 'a')
->leftJoin(
'a', 'talk_sessions', 's',
$query->expr()->eq('s.attendee_id', 'a.id')
)
->where($query->expr()->in('a.room_id', $query->createNamedParameter($roomIds, IQueryBuilder::PARAM_INT_ARRAY)));
return $this->getParticipantsForRoomsFromQuery($query, $map);
}
/**
* @param Room $room
* @param int $maxAge
* @return Participant[]
*/
public function getParticipantsForAllSessions(Room $room, int $maxAge = 0): array {
$query = $this->connection->getQueryBuilder();
$helper = new SelectHelper();
$helper->selectAttendeesTable($query);
$helper->selectSessionsTable($query);
$query->from('talk_sessions', 's')
->leftJoin(
's', 'talk_attendees', 'a',
$query->expr()->eq('s.attendee_id', 'a.id')
)
->where($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->isNotNull('a.id'));
if ($maxAge > 0) {
$query->andWhere($query->expr()->gt('s.last_ping', $query->createNamedParameter($maxAge, IQueryBuilder::PARAM_INT)));
}
return $this->getParticipantsFromQuery($query, $room);
}
/**
* @param Room $room
* @param int $maxAge
* @return Participant[]
*/
public function getParticipantsInCall(Room $room, int $maxAge = 0): array {
$query = $this->connection->getQueryBuilder();
$helper = new SelectHelper();
$helper->selectAttendeesTable($query);
$helper->selectSessionsTable($query);
$query->from('talk_sessions', 's')
->leftJoin(
's', 'talk_attendees', 'a',
$query->expr()->eq('s.attendee_id', 'a.id')
)
->where($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->neq('s.in_call', $query->createNamedParameter(Participant::FLAG_DISCONNECTED)));
if ($maxAge > 0) {
$query->andWhere($query->expr()->gte('s.last_ping', $query->createNamedParameter($maxAge, IQueryBuilder::PARAM_INT)));
}
return $this->getParticipantsFromQuery($query, $room);
}
/**
* @param Room $room
* @param int $notificationLevel
* @return Participant[]
*/
public function getParticipantsByNotificationLevel(Room $room, int $notificationLevel): array {
$query = $this->connection->getQueryBuilder();
$helper = new SelectHelper();
$helper->selectAttendeesTable($query);
$helper->selectSessionsTable($query);
$query->from('talk_attendees', 'a')
// Currently we only care if the user has an active session at all, so we can select any
->leftJoin(
'a', 'talk_sessions', 's',
$query->expr()->andX(
$query->expr()->eq('s.attendee_id', 'a.id'),
$query->expr()->eq('s.state', $query->createNamedParameter(Session::STATE_ACTIVE, IQueryBuilder::PARAM_INT))
)
)
->where($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('a.notification_level', $query->createNamedParameter($notificationLevel, IQueryBuilder::PARAM_INT)));
$participants = $this->getParticipantsFromQuery($query, $room);
$uniqueAttendees = [];
foreach ($participants as $participant) {
$uniqueAttendees[$participant->getAttendee()->getId()] = $participant;
}
return array_values($uniqueAttendees);
}
/**
* @return Participant[]
*/
public function getParticipantsByActorType(Room $room, string $actorType): array {
$attendees = $this->getActorsByType($room, $actorType);
return array_map(static fn (Attendee $attendee) => new Participant($room, $attendee, null), $attendees);
}
/**
* @param IQueryBuilder $query
* @param Room $room
* @return Participant[]
*/
protected function getParticipantsFromQuery(IQueryBuilder $query, Room $room): array {
return $this->getParticipantsForRoomsFromQuery($query, [$room->getId() => $room]);
}
/**
* @param IQueryBuilder $query
* @param Room[] $rooms Room ID => Room object
* @psalm-param array $rooms
* @return Participant[]
*/
protected function getParticipantsForRoomsFromQuery(IQueryBuilder $query, array $rooms): array {
$participants = [];
$result = $query->executeQuery();
while ($row = $result->fetch()) {
$room = $rooms[(int) $row['room_id']] ?? null;
if ($room === null) {
continue;
}
$attendee = $this->attendeeMapper->createAttendeeFromRow($row);
if (isset($row['s_id'])) {
$session = $this->sessionMapper->createSessionFromRow($row);
} else {
$session = null;
}
$participants[] = new Participant($room, $attendee, $session);
}
$result->closeCursor();
return $participants;
}
/**
* @param IQueryBuilder $query
* @return Participant
* @throws ParticipantNotFoundException
*/
protected function getParticipantFromQuery(IQueryBuilder $query, Room $room): Participant {
$result = $query->executeQuery();
$row = $result->fetch();
$result->closeCursor();
if ($row === false) {
throw new ParticipantNotFoundException('User is not a participant');
}
$attendee = $this->attendeeMapper->createAttendeeFromRow($row);
if (isset($row['s_id'])) {
$session = $this->sessionMapper->createSessionFromRow($row);
} else {
$session = null;
}
return new Participant($room, $attendee, $session);
}
/**
* @param Room $room
* @param \DateTime|null $maxLastJoined
* @return string[]
*/
public function getParticipantUserIds(Room $room, \DateTime $maxLastJoined = null): array {
$maxLastJoinedTimestamp = null;
if ($maxLastJoined !== null) {
$maxLastJoinedTimestamp = $maxLastJoined->getTimestamp();
}
$attendees = $this->attendeeMapper->getActorsByType($room->getId(), Attendee::ACTOR_USERS, $maxLastJoinedTimestamp);
return array_map(static function (Attendee $attendee) {
return $attendee->getActorId();
}, $attendees);
}
/**
* @param Room $room
* @param \DateTime|null $maxLastJoined
* @return int
*/
public function getGuestCount(Room $room, \DateTime $maxLastJoined = null): int {
$maxLastJoinedTimestamp = null;
if ($maxLastJoined !== null) {
$maxLastJoinedTimestamp = $maxLastJoined->getTimestamp();
}
return $this->attendeeMapper->getActorsCountByType($room->getId(), Attendee::ACTOR_GUESTS, $maxLastJoinedTimestamp);
}
/**
* @param Room $room
* @return string[]
*/
public function getParticipantUserIdsForCallNotifications(Room $room): array {
$query = $this->connection->getQueryBuilder();
$query->select('a.actor_id')
->from('talk_attendees', 'a')
->leftJoin(
'a', 'talk_sessions', 's',
$query->expr()->andX(
$query->expr()->eq('s.attendee_id', 'a.id'),
$query->expr()->neq('s.in_call', $query->createNamedParameter(Participant::FLAG_DISCONNECTED)),
$query->expr()->gte('s.last_ping', $query->createNamedParameter($this->timeFactory->getTime() - Session::SESSION_TIMEOUT, IQueryBuilder::PARAM_INT)),
)
)
->where($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('a.actor_type', $query->createNamedParameter(Attendee::ACTOR_USERS)))
->andWhere($query->expr()->eq('a.notification_calls', $query->createNamedParameter(Participant::NOTIFY_CALLS_ON)))
->andWhere($query->expr()->isNull('s.in_call'));
if ($room->getLobbyState() !== Webinary::LOBBY_NONE) {
// Filter out non-moderators and users without lobby permissions
$query->andWhere(
$query->expr()->orX(
$query->expr()->in('a.participant_type', $query->createNamedParameter(
[Participant::MODERATOR, Participant::OWNER],
IQueryBuilder::PARAM_INT_ARRAY
)),
$query->expr()->eq(
$query->expr()->castColumn(
$query->expr()->bitwiseAnd(
'permissions',
Attendee::PERMISSIONS_LOBBY_IGNORE
),
IQueryBuilder::PARAM_INT
),
$query->createNamedParameter(Attendee::PERMISSIONS_LOBBY_IGNORE, IQueryBuilder::PARAM_INT)
)
)
);
}
$userIds = [];
$result = $query->executeQuery();
while ($row = $result->fetch()) {
$userIds[] = $row['actor_id'];
}
$result->closeCursor();
return $userIds;
}
/**
* @param Room $room
* @return int
*/
public function getNumberOfUsers(Room $room): int {
return $this->attendeeMapper->countActorsByParticipantType($room->getId(), [
Participant::USER,
Participant::MODERATOR,
Participant::OWNER,
]);
}
/**
* @param Room $room
* @param bool $ignoreGuestModerators
* @return int
*/
public function getNumberOfModerators(Room $room, bool $ignoreGuestModerators = true): int {
$participantTypes = [
Participant::MODERATOR,
Participant::OWNER,
];
if (!$ignoreGuestModerators) {
$participantTypes[] = Participant::GUEST_MODERATOR;
}
return $this->attendeeMapper->countActorsByParticipantType($room->getId(), $participantTypes);
}
/**
* @param Room $room
* @return int
*/
public function getNumberOfActors(Room $room): int {
return $this->attendeeMapper->countActorsByParticipantType($room->getId(), []);
}
/**
* @param Room $room
* @return bool
*/
public function hasActiveSessions(Room $room): bool {
$query = $this->connection->getQueryBuilder();
$query->select('a.room_id')
->from('talk_attendees', 'a')
->leftJoin('a', 'talk_sessions', 's', $query->expr()->eq(
'a.id', 's.attendee_id'
))
->where($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->isNotNull('s.id'))
->setMaxResults(1);
$result = $query->executeQuery();
$row = $result->fetch();
$result->closeCursor();
return (bool) $row;
}
public function cacheParticipant(Room $room, Participant $participant): void {
$attendee = $participant->getAttendee();
if ($attendee->getActorType() !== Attendee::ACTOR_USERS) {
return;
}
$this->userCache[$room->getId()] ??= [];
$this->userCache[$room->getId()][$attendee->getActorId()] = $participant;
if ($participant->getSession()) {
$participantSessionId = $participant->getSession()->getSessionId();
$this->sessionCache[$room->getId()] ??= [];
$this->sessionCache[$room->getId()][$participantSessionId] = $participant;
}
}
/**
* @param Room $room
* @return bool
*/
public function hasActiveSessionsInCall(Room $room): bool {
$query = $this->connection->getQueryBuilder();
$query->select('a.room_id')
->from('talk_attendees', 'a')
->leftJoin('a', 'talk_sessions', 's', $query->expr()->eq(
'a.id', 's.attendee_id'
))
->where($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->isNotNull('s.in_call'))
->andWhere($query->expr()->neq('s.in_call', $query->createNamedParameter(Participant::FLAG_DISCONNECTED)))
->andWhere($query->expr()->gte('s.last_ping', $query->createNamedParameter($this->timeFactory->getTime() - Session::SESSION_TIMEOUT, IQueryBuilder::PARAM_INT)))
->setMaxResults(1);
$result = $query->executeQuery();
$row = $result->fetch();
$result->closeCursor();
return (bool) $row;
}
protected function generatePin(int $entropy = 7): string {
$pin = '';
// Do not allow to start with a '0' as that is a special mode on the phone server
// Also there are issues with some providers when you enter the same number twice
// consecutive too fast, so we avoid this as well.
$lastDigit = '0';
for ($i = 0; $i < $entropy; $i++) {
$lastDigit = $this->secureRandom->generate(1,
str_replace($lastDigit, '', ISecureRandom::CHAR_DIGITS)
);
$pin .= $lastDigit;
}
return $pin;
}
/**
* @param Room $room
* @param string|null $userId
* @param string|null|false $sessionId Set to false if you don't want to load a session (and save resources),
* string to try loading a specific session
* null to try loading "any"
* @return Participant
* @throws ParticipantNotFoundException When the user is not a participant
*/
public function getParticipant(Room $room, ?string $userId, $sessionId = null): Participant {
if (!is_string($userId) || $userId === '') {
throw new ParticipantNotFoundException('Not a user');
}
if (isset($this->userCache[$room->getId()][$userId])) {
$participant = $this->userCache[$room->getId()][$userId];
if (!$sessionId
|| ($participant->getSession() instanceof Session
&& $participant->getSession()->getSessionId() === $sessionId)) {
return $participant;
}
}
$query = $this->connection->getQueryBuilder();
$helper = new SelectHelper();
$helper->selectAttendeesTable($query);
$query->from('talk_attendees', 'a')
->where($query->expr()->eq('a.actor_type', $query->createNamedParameter(Attendee::ACTOR_USERS)))
->andWhere($query->expr()->eq('a.actor_id', $query->createNamedParameter($userId)))
->andWhere($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId())))
->setMaxResults(1);
if ($sessionId !== false) {
if ($sessionId !== null) {
$helper->selectSessionsTable($query);
$query->leftJoin('a', 'talk_sessions', 's', $query->expr()->andX(
$query->expr()->eq('s.session_id', $query->createNamedParameter($sessionId)),
$query->expr()->eq('a.id', 's.attendee_id')
));
} else {
$helper->selectSessionsTable($query); // FIXME PROBLEM
$query->leftJoin('a', 'talk_sessions', 's', $query->expr()->eq('a.id', 's.attendee_id'));
}
}
$participant = $this->getParticipantFromQuery($query, $room);
$this->userCache[$room->getId()] ??= [];
$this->userCache[$room->getId()][$userId] = $participant;
if ($participant->getSession()) {
$participantSessionId = $participant->getSession()->getSessionId();
$this->sessionCache[$room->getId()] ??= [];
$this->sessionCache[$room->getId()][$participantSessionId] = $participant;
}
return $participant;
}
/**
* @param Room $room
* @param string|null $sessionId
* @return Participant
* @throws ParticipantNotFoundException When the user is not a participant
*/
public function getParticipantBySession(Room $room, ?string $sessionId): Participant {
if (!is_string($sessionId) || $sessionId === '' || $sessionId === '0') {
throw new ParticipantNotFoundException('Not a user');
}
$query = $this->connection->getQueryBuilder();
$helper = new SelectHelper();
$helper->selectAttendeesTable($query);
$helper->selectSessionsTable($query);
$query->from('talk_sessions', 's')
->leftJoin('s', 'talk_attendees', 'a', $query->expr()->eq('a.id', 's.attendee_id'))
->where($query->expr()->eq('s.session_id', $query->createNamedParameter($sessionId)))
->andWhere($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId())))
->setMaxResults(1);
return $this->getParticipantFromQuery($query, $room);
}
/**
* @param Room $room
* @param string $pin
* @return Participant
* @throws ParticipantNotFoundException When the pin is not valid (has no participant assigned)
*/
public function getParticipantByPin(Room $room, string $pin): Participant {
$query = $this->connection->getQueryBuilder();
$helper = new SelectHelper();
$helper->selectAttendeesTable($query);
$query->from('talk_attendees', 'a')
->where($query->expr()->eq('a.pin', $query->createNamedParameter($pin)))
->andWhere($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId())))
->setMaxResults(1);
return $this->getParticipantFromQuery($query, $room);
}
/**
* @param Room $room
* @param int $attendeeId
* @return Participant
* @throws ParticipantNotFoundException When the pin is not valid (has no participant assigned)
*/
public function getParticipantByAttendeeId(Room $room, int $attendeeId): Participant {
$query = $this->connection->getQueryBuilder();
$helper = new SelectHelper();
$helper->selectAttendeesTable($query);
$query->from('talk_attendees', 'a')
->where($query->expr()->eq('a.id', $query->createNamedParameter($attendeeId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId())))
->setMaxResults(1);
return $this->getParticipantFromQuery($query, $room);
}
/**
* @param Room $room
* @param string $actorType
* @param string $actorId
* @return Participant
* @throws ParticipantNotFoundException When the pin is not valid (has no participant assigned)
*/
public function getParticipantByActor(Room $room, string $actorType, string $actorId): Participant {
if ($actorType === Attendee::ACTOR_USERS) {
return $this->getParticipant($room, $actorId, false);
}
$query = $this->connection->getQueryBuilder();
$helper = new SelectHelper();
$helper->selectAttendeesTable($query);
$query->from('talk_attendees', 'a')
->andWhere($query->expr()->eq('a.actor_type', $query->createNamedParameter($actorType)))
->andWhere($query->expr()->eq('a.actor_id', $query->createNamedParameter($actorId)))
->andWhere($query->expr()->eq('a.room_id', $query->createNamedParameter($room->getId())))
->setMaxResults(1);
return $this->getParticipantFromQuery($query, $room);
}
}