0
0
Fork 0
mirror of https://github.com/nextcloud/server.git synced 2025-01-31 06:43:12 +00:00
nextcloud_server/apps/user_status/lib/Service/StatusService.php
Joas Schilling 2c977d2b6e
fix(userstatus): Fix user status automation in real-life scenario
Order of applying:
- Out-of-office
- Availability
- Call
- Meeting
- User status

Signed-off-by: Joas Schilling <coding@schilljs.com>
2024-06-25 09:29:35 +02:00

587 lines
19 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\UserStatus\Service;
use OCA\UserStatus\Db\UserStatus;
use OCA\UserStatus\Db\UserStatusMapper;
use OCA\UserStatus\Exception\InvalidClearAtException;
use OCA\UserStatus\Exception\InvalidMessageIdException;
use OCA\UserStatus\Exception\InvalidStatusIconException;
use OCA\UserStatus\Exception\InvalidStatusTypeException;
use OCA\UserStatus\Exception\StatusMessageTooLongException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\DB\Exception;
use OCP\IConfig;
use OCP\IEmojiHelper;
use OCP\IUserManager;
use OCP\UserStatus\IUserStatus;
use Psr\Log\LoggerInterface;
use function in_array;
/**
* Class StatusService
*
* @package OCA\UserStatus\Service
*/
class StatusService {
private bool $shareeEnumeration;
private bool $shareeEnumerationInGroupOnly;
private bool $shareeEnumerationPhone;
/**
* List of priorities ordered by their priority
*/
public const PRIORITY_ORDERED_STATUSES = [
IUserStatus::ONLINE,
IUserStatus::AWAY,
IUserStatus::DND,
IUserStatus::BUSY,
IUserStatus::INVISIBLE,
IUserStatus::OFFLINE,
];
/**
* List of statuses that persist the clear-up
* or UserLiveStatusEvents
*/
public const PERSISTENT_STATUSES = [
IUserStatus::AWAY,
IUserStatus::BUSY,
IUserStatus::DND,
IUserStatus::INVISIBLE,
];
/** @var int */
public const INVALIDATE_STATUS_THRESHOLD = 15 /* minutes */ * 60 /* seconds */;
/** @var int */
public const MAXIMUM_MESSAGE_LENGTH = 80;
public function __construct(
private UserStatusMapper $mapper,
private ITimeFactory $timeFactory,
private PredefinedStatusService $predefinedStatusService,
private IEmojiHelper $emojiHelper,
private IConfig $config,
private IUserManager $userManager,
private LoggerInterface $logger,
) {
$this->shareeEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
$this->shareeEnumerationInGroupOnly = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
$this->shareeEnumerationPhone = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
}
/**
* @param int|null $limit
* @param int|null $offset
* @return UserStatus[]
*/
public function findAll(?int $limit = null, ?int $offset = null): array {
// Return empty array if user enumeration is disabled or limited to groups
// TODO: find a solution that scales to get only users from common groups if user enumeration is limited to
// groups. See discussion at https://github.com/nextcloud/server/pull/27879#discussion_r729715936
if (!$this->shareeEnumeration || $this->shareeEnumerationInGroupOnly || $this->shareeEnumerationPhone) {
return [];
}
return array_map(function ($status) {
return $this->processStatus($status);
}, $this->mapper->findAll($limit, $offset));
}
/**
* @param int|null $limit
* @param int|null $offset
* @return array
*/
public function findAllRecentStatusChanges(?int $limit = null, ?int $offset = null): array {
// Return empty array if user enumeration is disabled or limited to groups
// TODO: find a solution that scales to get only users from common groups if user enumeration is limited to
// groups. See discussion at https://github.com/nextcloud/server/pull/27879#discussion_r729715936
if (!$this->shareeEnumeration || $this->shareeEnumerationInGroupOnly || $this->shareeEnumerationPhone) {
return [];
}
return array_map(function ($status) {
return $this->processStatus($status);
}, $this->mapper->findAllRecent($limit, $offset));
}
/**
* @param string $userId
* @return UserStatus
* @throws DoesNotExistException
*/
public function findByUserId(string $userId): UserStatus {
return $this->processStatus($this->mapper->findByUserId($userId));
}
/**
* @param array $userIds
* @return UserStatus[]
*/
public function findByUserIds(array $userIds):array {
return array_map(function ($status) {
return $this->processStatus($status);
}, $this->mapper->findByUserIds($userIds));
}
/**
* @param string $userId
* @param string $status
* @param int|null $statusTimestamp
* @param bool $isUserDefined
* @return UserStatus
* @throws InvalidStatusTypeException
*/
public function setStatus(string $userId,
string $status,
?int $statusTimestamp,
bool $isUserDefined): UserStatus {
try {
$userStatus = $this->mapper->findByUserId($userId);
} catch (DoesNotExistException $ex) {
$userStatus = new UserStatus();
$userStatus->setUserId($userId);
}
// Check if status-type is valid
if (!in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
throw new InvalidStatusTypeException('Status-type "' . $status . '" is not supported');
}
if ($statusTimestamp === null) {
$statusTimestamp = $this->timeFactory->getTime();
}
$userStatus->setStatus($status);
$userStatus->setStatusTimestamp($statusTimestamp);
$userStatus->setIsUserDefined($isUserDefined);
$userStatus->setIsBackup(false);
if ($userStatus->getId() === null) {
return $this->mapper->insert($userStatus);
}
return $this->mapper->update($userStatus);
}
/**
* @param string $userId
* @param string $messageId
* @param int|null $clearAt
* @return UserStatus
* @throws InvalidMessageIdException
* @throws InvalidClearAtException
*/
public function setPredefinedMessage(string $userId,
string $messageId,
?int $clearAt): UserStatus {
try {
$userStatus = $this->mapper->findByUserId($userId);
} catch (DoesNotExistException $ex) {
$userStatus = new UserStatus();
$userStatus->setUserId($userId);
$userStatus->setStatus(IUserStatus::OFFLINE);
$userStatus->setStatusTimestamp(0);
$userStatus->setIsUserDefined(false);
$userStatus->setIsBackup(false);
}
if (!$this->predefinedStatusService->isValidId($messageId)) {
throw new InvalidMessageIdException('Message-Id "' . $messageId . '" is not supported');
}
// Check that clearAt is in the future
if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
throw new InvalidClearAtException('ClearAt is in the past');
}
$userStatus->setMessageId($messageId);
$userStatus->setCustomIcon(null);
$userStatus->setCustomMessage(null);
$userStatus->setClearAt($clearAt);
$userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
if ($userStatus->getId() === null) {
return $this->mapper->insert($userStatus);
}
return $this->mapper->update($userStatus);
}
/**
* @param string $userId
* @param string $status
* @param string $messageId
* @param bool $createBackup
* @param string|null $customMessage
* @throws InvalidStatusTypeException
* @throws InvalidMessageIdException
*/
public function setUserStatus(string $userId,
string $status,
string $messageId,
bool $createBackup,
?string $customMessage = null): ?UserStatus {
// Check if status-type is valid
if (!in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
throw new InvalidStatusTypeException('Status-type "' . $status . '" is not supported');
}
if (!$this->predefinedStatusService->isValidId($messageId)) {
throw new InvalidMessageIdException('Message-Id "' . $messageId . '" is not supported');
}
try {
$userStatus = $this->mapper->findByUserId($userId);
} catch (DoesNotExistException $e) {
// We don't need to do anything
$userStatus = new UserStatus();
$userStatus->setUserId($userId);
}
$updateStatus = false;
if ($messageId === IUserStatus::MESSAGE_OUT_OF_OFFICE) {
// OUT_OF_OFFICE trumps AVAILABILITY, CALL and CALENDAR status
$updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_AVAILABILITY || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALL || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY;
} elseif ($messageId === IUserStatus::MESSAGE_AVAILABILITY) {
// AVAILABILITY trumps CALL and CALENDAR status
$updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_CALL || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY;
} elseif ($messageId === IUserStatus::MESSAGE_CALL) {
// CALL trumps CALENDAR status
$updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY;
}
if ($messageId === IUserStatus::MESSAGE_OUT_OF_OFFICE || $messageId === IUserStatus::MESSAGE_AVAILABILITY || $messageId === IUserStatus::MESSAGE_CALL || $messageId === IUserStatus::MESSAGE_CALENDAR_BUSY) {
if ($updateStatus) {
$this->logger->debug('User ' . $userId . ' is currently NOT available, overwriting status [status: ' . $userStatus->getStatus() . ', messageId: ' . json_encode($userStatus->getMessageId()) . ']', ['app' => 'dav']);
} else {
$this->logger->debug('User ' . $userId . ' is currently NOT available, but we are NOT overwriting status [status: ' . $userStatus->getStatus() . ', messageId: ' . json_encode($userStatus->getMessageId()) . ']', ['app' => 'dav']);
}
}
// There should be a backup already or none is needed. So we take a shortcut.
if ($updateStatus) {
$userStatus->setStatus($status);
$userStatus->setStatusTimestamp($this->timeFactory->getTime());
$userStatus->setIsUserDefined(true);
$userStatus->setIsBackup(false);
$userStatus->setMessageId($messageId);
$userStatus->setCustomIcon(null);
$userStatus->setCustomMessage($customMessage);
$userStatus->setClearAt(null);
$userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
return $this->mapper->update($userStatus);
}
if ($createBackup) {
if ($this->backupCurrentStatus($userId) === false) {
return null; // Already a status set automatically => abort.
}
// If we just created the backup
// we need to create a new status to insert
// Unfortunately there's no way to unset the DB ID on an Entity
$userStatus = new UserStatus();
$userStatus->setUserId($userId);
}
$userStatus->setStatus($status);
$userStatus->setStatusTimestamp($this->timeFactory->getTime());
$userStatus->setIsUserDefined(true);
$userStatus->setIsBackup(false);
$userStatus->setMessageId($messageId);
$userStatus->setCustomIcon(null);
$userStatus->setCustomMessage($customMessage);
$userStatus->setClearAt(null);
if ($this->predefinedStatusService->getTranslatedStatusForId($messageId) !== null
|| ($customMessage !== null && $customMessage !== '')) {
// Only track status message ID if there is one
$userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
} else {
$userStatus->setStatusMessageTimestamp(0);
}
if ($userStatus->getId() !== null) {
return $this->mapper->update($userStatus);
}
return $this->mapper->insert($userStatus);
}
/**
* @param string $userId
* @param string|null $statusIcon
* @param string|null $message
* @param int|null $clearAt
* @return UserStatus
* @throws InvalidClearAtException
* @throws InvalidStatusIconException
* @throws StatusMessageTooLongException
*/
public function setCustomMessage(string $userId,
?string $statusIcon,
?string $message,
?int $clearAt): UserStatus {
try {
$userStatus = $this->mapper->findByUserId($userId);
} catch (DoesNotExistException $ex) {
$userStatus = new UserStatus();
$userStatus->setUserId($userId);
$userStatus->setStatus(IUserStatus::OFFLINE);
$userStatus->setStatusTimestamp(0);
$userStatus->setIsUserDefined(false);
}
// Check if statusIcon contains only one character
if ($statusIcon !== null && !$this->emojiHelper->isValidSingleEmoji($statusIcon)) {
throw new InvalidStatusIconException('Status-Icon is longer than one character');
}
// Check for maximum length of custom message
if ($message !== null && \mb_strlen($message) > self::MAXIMUM_MESSAGE_LENGTH) {
throw new StatusMessageTooLongException('Message is longer than supported length of ' . self::MAXIMUM_MESSAGE_LENGTH . ' characters');
}
// Check that clearAt is in the future
if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
throw new InvalidClearAtException('ClearAt is in the past');
}
$userStatus->setMessageId(null);
$userStatus->setCustomIcon($statusIcon);
$userStatus->setCustomMessage($message);
$userStatus->setClearAt($clearAt);
$userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
if ($userStatus->getId() === null) {
return $this->mapper->insert($userStatus);
}
return $this->mapper->update($userStatus);
}
/**
* @param string $userId
* @return bool
*/
public function clearStatus(string $userId): bool {
try {
$userStatus = $this->mapper->findByUserId($userId);
} catch (DoesNotExistException $ex) {
// if there is no status to remove, just return
return false;
}
$userStatus->setStatus(IUserStatus::OFFLINE);
$userStatus->setStatusTimestamp(0);
$userStatus->setIsUserDefined(false);
$this->mapper->update($userStatus);
return true;
}
/**
* @param string $userId
* @return bool
*/
public function clearMessage(string $userId): bool {
try {
$userStatus = $this->mapper->findByUserId($userId);
} catch (DoesNotExistException $ex) {
// if there is no status to remove, just return
return false;
}
$userStatus->setMessageId(null);
$userStatus->setCustomMessage(null);
$userStatus->setCustomIcon(null);
$userStatus->setClearAt(null);
$userStatus->setStatusMessageTimestamp(0);
$this->mapper->update($userStatus);
return true;
}
/**
* @param string $userId
* @return bool
*/
public function removeUserStatus(string $userId): bool {
try {
$userStatus = $this->mapper->findByUserId($userId, false);
} catch (DoesNotExistException $ex) {
// if there is no status to remove, just return
return false;
}
$this->mapper->delete($userStatus);
return true;
}
public function removeBackupUserStatus(string $userId): bool {
try {
$userStatus = $this->mapper->findByUserId($userId, true);
} catch (DoesNotExistException $ex) {
// if there is no status to remove, just return
return false;
}
$this->mapper->delete($userStatus);
return true;
}
/**
* Processes a status to check if custom message is still
* up to date and provides translated default status if needed
*
* @param UserStatus $status
* @return UserStatus
*/
private function processStatus(UserStatus $status): UserStatus {
$clearAt = $status->getClearAt();
if ($status->getStatusTimestamp() < $this->timeFactory->getTime() - self::INVALIDATE_STATUS_THRESHOLD
&& (!$status->getIsUserDefined() || $status->getStatus() === IUserStatus::ONLINE)) {
$this->cleanStatus($status);
}
if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
$this->cleanStatus($status);
$this->cleanStatusMessage($status);
}
if ($status->getMessageId() !== null) {
$this->addDefaultMessage($status);
}
return $status;
}
/**
* @param UserStatus $status
*/
private function cleanStatus(UserStatus $status): void {
if ($status->getStatus() === IUserStatus::OFFLINE && !$status->getIsUserDefined()) {
return;
}
$status->setStatus(IUserStatus::OFFLINE);
$status->setStatusTimestamp($this->timeFactory->getTime());
$status->setIsUserDefined(false);
$this->mapper->update($status);
}
/**
* @param UserStatus $status
*/
private function cleanStatusMessage(UserStatus $status): void {
$status->setMessageId(null);
$status->setCustomIcon(null);
$status->setCustomMessage(null);
$status->setClearAt(null);
$status->setStatusMessageTimestamp(0);
$this->mapper->update($status);
}
/**
* @param UserStatus $status
*/
private function addDefaultMessage(UserStatus $status): void {
// If the message is predefined, insert the translated message and icon
$predefinedMessage = $this->predefinedStatusService->getDefaultStatusById($status->getMessageId());
if ($predefinedMessage === null) {
return;
}
// If there is a custom message, don't overwrite it
if(empty($status->getCustomMessage())) {
$status->setCustomMessage($predefinedMessage['message']);
}
if(empty($status->getCustomIcon())) {
$status->setCustomIcon($predefinedMessage['icon']);
}
}
/**
* @return bool false if there is already a backup. In this case abort the procedure.
*/
public function backupCurrentStatus(string $userId): bool {
try {
$this->mapper->createBackupStatus($userId);
return true;
} catch (Exception $ex) {
if ($ex->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
return false;
}
throw $ex;
}
}
public function revertUserStatus(string $userId, string $messageId, bool $revertedManually = false): ?UserStatus {
try {
/** @var UserStatus $userStatus */
$backupUserStatus = $this->mapper->findByUserId($userId, true);
} catch (DoesNotExistException $ex) {
// No user status to revert, do nothing
return null;
}
$deleted = $this->mapper->deleteCurrentStatusToRestoreBackup($userId, $messageId);
if (!$deleted) {
// Another status is set automatically or no status, do nothing
return null;
}
if ($revertedManually) {
if ($backupUserStatus->getStatus() === IUserStatus::OFFLINE) {
// When the user reverts the status manually they are online
$backupUserStatus->setStatus(IUserStatus::ONLINE);
}
$backupUserStatus->setStatusTimestamp($this->timeFactory->getTime());
}
$backupUserStatus->setIsBackup(false);
// Remove the underscore prefix added when creating the backup
$backupUserStatus->setUserId(substr($backupUserStatus->getUserId(), 1));
$this->mapper->update($backupUserStatus);
return $backupUserStatus;
}
public function revertMultipleUserStatus(array $userIds, string $messageId): void {
// Get all user statuses and the backups
$findById = $userIds;
foreach ($userIds as $userId) {
$findById[] = '_' . $userId;
}
$userStatuses = $this->mapper->findByUserIds($findById);
$backups = $restoreIds = $statuesToDelete = [];
foreach ($userStatuses as $userStatus) {
if (!$userStatus->getIsBackup()
&& $userStatus->getMessageId() === $messageId) {
$statuesToDelete[$userStatus->getUserId()] = $userStatus->getId();
} elseif ($userStatus->getIsBackup()) {
$backups[$userStatus->getUserId()] = $userStatus->getId();
}
}
// For users with both (normal and backup) delete the status when matching
foreach ($statuesToDelete as $userId => $statusId) {
$backupUserId = '_' . $userId;
if (isset($backups[$backupUserId])) {
$restoreIds[] = $backups[$backupUserId];
}
}
$this->mapper->deleteByIds(array_values($statuesToDelete));
// For users that matched restore the previous status
$this->mapper->restoreBackupStatuses($restoreIds);
}
}