Skip to content

Commit

Permalink
fix(userstatus): set user status to 'In a meeting' if calendar is busy
Browse files Browse the repository at this point in the history
Signed-off-by: Anna Larch <[email protected]>
  • Loading branch information
miaulalala committed Dec 15, 2023
1 parent 1db6947 commit 91ef6fc
Show file tree
Hide file tree
Showing 9 changed files with 128 additions and 302 deletions.
72 changes: 0 additions & 72 deletions apps/dav/lib/CalDAV/Status/Status.php

This file was deleted.

193 changes: 75 additions & 118 deletions apps/dav/lib/CalDAV/Status/StatusService.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,83 +25,103 @@
*/
namespace OCA\DAV\CalDAV\Status;

use DateTimeImmutable;
use OC\Calendar\CalendarQuery;
use OCA\DAV\CalDAV\CalendarImpl;
use OCA\DAV\CalDAV\FreeBusy\FreeBusyGenerator;
use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer;
use OCA\DAV\CalDAV\Schedule\Plugin as SchedulePlugin;
use OCA\UserStatus\Service\StatusService as UserStatusService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Calendar\IManager;
use OCP\IL10N;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IUser as User;
use OCP\IUserManager;
use OCP\UserStatus\IUserStatus;
use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp;
use Sabre\DAV\Exception\NotAuthenticated;
use Sabre\DAVACL\Exception\NeedPrivileges;
use Sabre\DAVACL\Plugin as AclPlugin;
use Sabre\VObject\Component;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Parameter;
use Sabre\VObject\Property;

class StatusService {
private ICache $cache;
public function __construct(private ITimeFactory $timeFactory,
private IManager $calendarManager,
private InvitationResponseServer $server,
private IL10N $l10n,
private FreeBusyGenerator $generator) {
private IUserManager $userManager,
private UserStatusService $userStatusService,
private ICacheFactory $cacheFactory) {
$this->cache = $cacheFactory->createLocal('CalendarStatusService');
}

public function processCalendarAvailability(User $user): ?Status {
$userId = $user->getUID();
$email = $user->getEMailAddress();
if($email === null) {
return null;
public function processCalendarStatus(string $userId): void {
$user = $this->userManager->get($userId);
if($user === null) {
return;
}
$calendarEvents = $this->cache->get($user->getUID());
if($calendarEvents === null) {
$calendarEvents = $this->getCalendarEvents($user);
$this->cache->set($user->getUID(), $calendarEvents, 300);
}

$server = $this->server->getServer();

/** @var SchedulePlugin $schedulingPlugin */
$schedulingPlugin = $server->getPlugin('caldav-schedule');
$caldavNS = '{'.$schedulingPlugin::NS_CALDAV.'}';

/** @var AclPlugin $aclPlugin */
$aclPlugin = $server->getPlugin('acl');
if ('mailto:' === substr($email, 0, 7)) {
$email = substr($email, 7);
if(empty($calendarEvents)) {
$this->userStatusService->revertUserStatus($user->getUID(), IUserStatus::MESSAGE_CALENDAR_BUSY);
return;
}

$result = $aclPlugin->principalSearch(
['{http://sabredav.org/ns}email-address' => $email],
[
'{DAV:}principal-URL',
$caldavNS.'calendar-home-set',
$caldavNS.'schedule-inbox-URL',
'{http://sabredav.org/ns}email-address',
]
);
$userStatusTimestamp = $currentStatus = null;
try {
$currentStatus = $this->userStatusService->findByUserId($user->getUID());
$userStatusTimestamp = $currentStatus->getIsUserDefined() ? $currentStatus->getStatusTimestamp() : null;
} catch (DoesNotExistException) {
}

if (!count($result) || !isset($result[0][200][$caldavNS.'schedule-inbox-URL'])) {
return null;
if($currentStatus !== null && $currentStatus->getMessageId() === IUserStatus::MESSAGE_CALL) {
// We don't overwrite the call status
return;
}

$inboxUrl = $result[0][200][$caldavNS.'schedule-inbox-URL']->getHref();
// Filter events to see if we have any that apply to the calendar status
$applicableEvents = array_filter($calendarEvents, function ($calendarEvent) use ($userStatusTimestamp) {
$component = $calendarEvent['objects'][0];
if(isset($component['X-NEXTCLOUD-OUT-OF-OFFICE'])) {
return false;
}
if(isset($component['DTSTART']) && $userStatusTimestamp !== null) {
/** @var DateTimeImmutable $dateTime */
$dateTime = $component['DTSTART'][0];
$timestamp = $dateTime->getTimestamp();
if($userStatusTimestamp > $timestamp) {
return false;
}
}
// Ignore events that are transparent
if(isset($component['TRANSP']) && strcasecmp($component['TRANSP'][0], 'TRANSPARENT') === 0) {
return false;
}
return true;
});

// Do we have permission?
try {
$aclPlugin->checkPrivileges($inboxUrl, $caldavNS.'schedule-query-freebusy');
} catch (NeedPrivileges | NotAuthenticated $exception) {
return null;
if(empty($applicableEvents)) {
$this->userStatusService->revertUserStatus($user->getUID(), IUserStatus::MESSAGE_CALENDAR_BUSY);
return;
}

$now = $this->timeFactory->now();
$calendarTimeZone = $now->getTimezone();
$calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId);
// One event that fulfills all status conditions is enough
// 1. Not an OOO event
// 2. Current user status was not set after the start of this event
// 3. Event is not set to be transparent
$this->userStatusService->setUserStatus(
$user->getUID(),
IUserStatus::AWAY,
IUserStatus::MESSAGE_CALENDAR_BUSY,
true
);
}

private function getCalendarEvents(User $user): array {
$calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $user->getUID());
if(empty($calendars)) {
return null;
return [];
}

$query = $this->calendarManager->newQuery('principals/users/' . $userId);
$query = $this->calendarManager->newQuery('principals/users/' . $user->getUID());
foreach ($calendars as $calendarObject) {
// We can only work with a calendar if it exposes its scheduling information
if (!$calendarObject instanceof CalendarImpl) {
Expand All @@ -114,83 +134,20 @@ public function processCalendarAvailability(User $user): ?Status {
// ignore it for free-busy purposes.
continue;
}

/** @var Component\VTimeZone|null $ctz */
$ctz = $calendarObject->getSchedulingTimezone();
if ($ctz !== null) {
$calendarTimeZone = $ctz->getTimeZone();
}
$query->addSearchCalendar($calendarObject->getUri());
}

$calendarEvents = [];
$dtStart = $now;
$dtEnd = \DateTimeImmutable::createFromMutable($this->timeFactory->getDateTime('+10 minutes'));
$dtStart = DateTimeImmutable::createFromMutable($this->timeFactory->getDateTime());
$dtEnd = DateTimeImmutable::createFromMutable($this->timeFactory->getDateTime('+10 minutes'));

// Only query the calendars when there's any to search
if($query instanceof CalendarQuery && !empty($query->getCalendarUris())) {
// Query the next hour
$query->setTimerangeStart($dtStart);
$query->setTimerangeEnd($dtEnd);
$calendarEvents = $this->calendarManager->searchForPrincipal($query);
return $this->calendarManager->searchForPrincipal($query);
}

// @todo we can cache that
if(empty($calendarEvents)) {
return null;
}

$calendar = $this->generator->getVCalendar();
foreach ($calendarEvents as $calendarEvent) {
$vEvent = new VEvent($calendar, 'VEVENT');
foreach($calendarEvent['objects'] as $component) {
foreach ($component as $key => $value) {
$vEvent->add($key, $value[0]);
}
}
$calendar->add($vEvent);
}

$calendar->METHOD = 'REQUEST';

$this->generator->setObjects($calendar);
$this->generator->setTimeRange($dtStart, $dtEnd);
$this->generator->setTimeZone($calendarTimeZone);
$result = $this->generator->getResult();

if (!isset($result->VFREEBUSY)) {
return null;
}

/** @var Component $freeBusyComponent */
$freeBusyComponent = $result->VFREEBUSY;
$freeBusyProperties = $freeBusyComponent->select('FREEBUSY');
// If there is no FreeBusy property, the time-range is empty and available
if (count($freeBusyProperties) === 0) {
return null;
}

/** @var Property $freeBusyProperty */
$freeBusyProperty = $freeBusyProperties[0];
if (!$freeBusyProperty->offsetExists('FBTYPE')) {
// If there is no FBTYPE, it means it's busy from a regular event
return new Status(IUserStatus::BUSY, IUserStatus::MESSAGE_CALENDAR_BUSY);
}

// If we can't deal with the FBTYPE (custom properties are a possibility)
// we should ignore it and leave the current status
$fbTypeParameter = $freeBusyProperty->offsetGet('FBTYPE');
if (!($fbTypeParameter instanceof Parameter)) {
return null;
}
$fbType = $fbTypeParameter->getValue();
switch ($fbType) {
// Ignore BUSY-UNAVAILABLE, that's for the automation
case 'BUSY':
case 'BUSY-TENTATIVE':
return new Status(IUserStatus::BUSY, IUserStatus::MESSAGE_CALENDAR_BUSY, $this->l10n->t('In a meeting'));
default:
return null;
}
return [];
}
}
25 changes: 7 additions & 18 deletions apps/dav/tests/unit/CalDAV/Status/StatusServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,33 +22,22 @@
*/
namespace OCA\DAV\Tests\unit\CalDAV\Status;

use OC\Calendar\CalendarQuery;
use OCA\DAV\CalDAV\CalendarImpl;
use OCA\DAV\CalDAV\FreeBusy\FreeBusyGenerator;
use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer;
use OCA\DAV\CalDAV\Schedule\Plugin;
use OCA\DAV\CalDAV\Status\StatusService;
use OCA\DAV\Connector\Sabre\Server;
use OCA\UserStatus\Service\StatusService as UserStatusService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Calendar\IManager;
use OCP\IL10N;
use OCP\IUser;
use OCP\Files\Cache\ICache;
use OCP\ICacheFactory;
use OCP\IUserManager;
use PHPUnit\Framework\MockObject\MockObject;
use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp;
use Sabre\DAV\Exception\NotAuthenticated;
use Sabre\DAV\Xml\Property\LocalHref;
use Sabre\DAVACL\Exception\NeedPrivileges;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VTimeZone;
use Sabre\VObject\Reader;
use Test\TestCase;

class StatusServiceTest extends TestCase {
private ITimeFactory|MockObject $timeFactory;
private IManager|MockObject $calendarManager;
private InvitationResponseServer|MockObject $server;
private IL10N|MockObject $l10n;
private FreeBusyGenerator|MockObject $generator;
private IUserManager|MockObject $userManager;
private UserStatusService|MockObject $userStatusService;
private ICacheFactory|MockObject $cacheFactory;
private StatusService $service;

protected function setUp(): void {
Expand Down
3 changes: 3 additions & 0 deletions apps/user_status/lib/Controller/UserStatusController.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
*/
namespace OCA\UserStatus\Controller;

use OCA\DAV\CalDAV\Status\StatusService as CalendarStatusService;
use OCA\UserStatus\Db\UserStatus;
use OCA\UserStatus\Exception\InvalidClearAtException;
use OCA\UserStatus\Exception\InvalidMessageIdException;
Expand Down Expand Up @@ -55,6 +56,7 @@ public function __construct(
private string $userId,
private LoggerInterface $logger,
private StatusService $service,
private CalendarStatusService $calendarStatusService,
) {
parent::__construct($appName, $request);
}
Expand All @@ -71,6 +73,7 @@ public function __construct(
*/
public function getStatus(): DataResponse {
try {
$this->calendarStatusService->processCalendarStatus($this->userId);
$userStatus = $this->service->findByUserId($this->userId);
} catch (DoesNotExistException $ex) {
throw new OCSNotFoundException('No status for the current user');
Expand Down
6 changes: 6 additions & 0 deletions apps/user_status/lib/Listener/UserLiveStatusListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ public function handle(Event $event): void {
return;
}

// Don't overwrite the "away" calendar status if it's set
if($userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY) {
$event->setUserStatus(new ConnectorUserStatus($userStatus));
return;
}

$needsUpdate = false;

// If the current status is older than 5 minutes,
Expand Down
Loading

0 comments on commit 91ef6fc

Please sign in to comment.