Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit fea79ff8 authored by Alexandre Roux's avatar Alexandre Roux
Browse files

auto create a talk room when creating event with 2 or more attendees

parent 575f73d5
Loading
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -11,12 +11,14 @@ use OCA\Calendar\Dashboard\CalendarWidget;
use OCA\Calendar\Events\BeforeAppointmentBookedEvent;
use OCA\Calendar\Listener\AppointmentBookedListener;
use OCA\Calendar\Listener\CalendarReferenceListener;
use OCA\Calendar\Listener\SabrePluginAddListener;
use OCA\Calendar\Listener\UserDeletedListener;
use OCA\Calendar\Middleware\InvitationMiddleware;
use OCA\Calendar\Notification\Notifier;
use OCA\Calendar\Profile\AppointmentsAction;
use OCA\Calendar\Reference\ReferenceProvider;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\Events\SabrePluginAddEvent;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
@@ -116,6 +118,7 @@ class Application extends App implements IBootstrap {
		$context->registerEventListener(BeforeAppointmentBookedEvent::class, AppointmentBookedListener::class);
		$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
		$context->registerEventListener(RenderReferenceEvent::class, CalendarReferenceListener::class);
		$context->registerEventListener(SabrePluginAddEvent::class, SabrePluginAddListener::class);

		$context->registerNotifierService(Notifier::class);
	}
+225 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace OCA\Calendar\Dav;

use OCA\Talk\Service\RoomService;
use OCP\App\IAppManager;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserSession;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Sabre\DAV\INode;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\VObject\Document;
use Sabre\VObject\Reader;

class BeforeCreateFilePlugin extends ServerPlugin {

	/**
	 * A reference to the main Server class.
	 *
	 * @var \Sabre\DAV\Server
	 */
	protected $server;

	public function __construct(
		private IAppManager $appManager,
		private IUserSession $userSession,
		private IURLGenerator $urlGenerator,
		private ContainerInterface $container,
		private LoggerInterface $logger,
	) {
	}

	/**
	 * Initialize the plugin.
	 *
	 * This is called automatically be the Server class after this plugin is
	 * added with Sabre\DAV\Server::addPlugin()
	 */
	public function initialize(Server $server) {
		$this->server = $server;
		$server->on('beforeCreateFile', [$this, 'beforeCreateFile']);
		$server->on('beforeWriteContent', [$this, 'beforeWriteContent']);
		$this->logger->info('BeforeCreateFilePlugin initialized on Sabre server', ['app' => 'calendar']);
	}



	/**
	 * Assign a Nextcloud Talk room URL as the event location
	 *
	 * @param string $uri target file URI
	 * @param resource $data data
	 * @param INode $parent Sabre Node
	 * @param bool $modified modified
	 * @return
	 */

	public function beforeCreateFile($uri, &$data, INode $parent, &$modified) {
		$this->logger->debug('beforeCreateFile fired', ['app' => 'calendar', 'uri' => $uri]);
		if (is_resource($data)) {
			$data = stream_get_contents($data);
		}
		// Parse the data as a vCalendar object using Sabre\VObject.
		try {
			$vCalendar = Reader::read($data);
		} catch (\Exception $e) {
			// The data is not a valid vCalendar object.
			$this->logger->debug('beforeCreateFile: payload is not valid vCalendar, skipping', [
				'app' => 'calendar',
				'uri' => $uri,
				'exception' => $e,
			]);
			return;
		}
		$this->processEventData($data, $vCalendar);

	}

	/**
	 * Assign a Nextcloud Talk room URL as the event location
	 *
	 * @param string $uri target file URI
	 * @param INode $parent Sabre Node
	 * @param resource $data data
	 * @param bool $modified modified
	 * @return
	 */

	public function beforeWriteContent($uri, INode $parent, &$data, &$modified) {
		$this->logger->debug('beforeWriteContent fired', ['app' => 'calendar', 'uri' => $uri]);
		if (is_resource($data)) {
			$data = stream_get_contents($data);
		}
		// Parse the data as a vCalendar object using Sabre\VObject.
		try {
			$vCalendar = Reader::read($data);
		} catch (\Exception $e) {
			// The data is not a valid vCalendar object.
			$this->logger->debug('beforeWriteContent: payload is not valid vCalendar, skipping', [
				'app' => 'calendar',
				'uri' => $uri,
				'exception' => $e,
			]);
			return;
		}
		$this->processEventData($data, $vCalendar);
	}

	/**
	 * Process event data and, for an event that has attendees but no location,
	 * create a Nextcloud Talk room and inject its URL as the LOCATION.
	 *
	 * @param string $data Raw iCalendar payload, mutated in place
	 * @param Document $vCalendar Parsed vCalendar, used to read the event title
	 */
	private function processEventData(string &$data, Document $vCalendar): void {
		$hasAttendee = strpos($data, 'ATTENDEE;') !== false;
		$hasLocation = strpos($data, 'LOCATION:') !== false;
		$this->logger->debug('processEventData: inspecting vCalendar payload', [
			'app' => 'calendar',
			'hasAttendee' => $hasAttendee,
			'hasLocation' => $hasLocation,
		]);

		if (!$hasAttendee || $hasLocation) {
			$this->logger->debug('processEventData: skipping, event has no attendees or already has a location', [
				'app' => 'calendar',
				'hasAttendee' => $hasAttendee,
				'hasLocation' => $hasLocation,
			]);
			return;
		}

		$meetingURL = $this->createTalkRoom($vCalendar);
		if ($meetingURL === null) {
			$this->logger->debug('processEventData: no Talk room URL, leaving event unchanged', ['app' => 'calendar']);
			return;
		}

		// Insert LOCATION on the first VEVENT only
		$data = preg_replace(
			'/BEGIN:VEVENT/',
			"BEGIN:VEVENT\r\nLOCATION:$meetingURL",
			$data,
			1,
		);
		$this->logger->info('processEventData: injected Talk room LOCATION into event', [
			'app' => 'calendar',
			'url' => $meetingURL,
		]);
	}

	/**
	 * Create a public Talk room for this event and return its absolute URL.
	 * Returns null if Talk is unavailable, no user is logged in, or room
	 * creation fails for any reason.
	 */
	private function createTalkRoom(Document $vCalendar): ?string {
		$spreedEnabled = $this->appManager->isEnabledForUser('spreed');
		$roomServiceExists = class_exists(RoomService::class);
		if (!$spreedEnabled || !$roomServiceExists) {
			$this->logger->info('createTalkRoom: Talk unavailable, skipping', [
				'app' => 'calendar',
				'spreedEnabled' => $spreedEnabled,
				'roomServiceExists' => $roomServiceExists,
			]);
			return null;
		}

		$user = $this->userSession->getUser();
		if (!$user instanceof IUser) {
			$this->logger->warning('createTalkRoom: no current user in session, skipping', ['app' => 'calendar']);
			return null;
		}

		$title = 'Talk conversation for event';
		if (isset($vCalendar->VEVENT, $vCalendar->VEVENT->SUMMARY)) {
			$summary = trim((string)$vCalendar->VEVENT->SUMMARY);
			if ($summary !== '') {
				$title = $summary;
			}
		}
		$this->logger->debug('createTalkRoom: creating public Talk room', [
			'app' => 'calendar',
			'user' => $user->getUID(),
			'title' => $title,
		]);

		try {
			/** @var RoomService $roomService */
			$roomService = $this->container->get(RoomService::class);
			// Talk room type 3 is a public conversation, matching the
			// frontend's createTalkRoom in src/services/talkService.js.
			$room = $roomService->createConversation(
				3,
				$title,
				$user,
				'event',
				md5((string)microtime(true)),
			);

			$url = $this->urlGenerator->linkToRouteAbsolute(
				'spreed.Page.showCall',
				['token' => $room->getToken()],
			);
			$this->logger->info('createTalkRoom: Talk room created', [
				'app' => 'calendar',
				'token' => $room->getToken(),
				'url' => $url,
			]);
			return $url;
		} catch (\Throwable $e) {
			$this->logger->warning(
				'Could not create Talk room for CalDAV event: ' . $e->getMessage(),
				['app' => 'calendar', 'exception' => $e],
			);
			return null;
		}
	}
}
+38 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

/**
 * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

namespace OCA\Calendar\Listener;

use OCA\Calendar\AppInfo\Application;
use OCA\Calendar\Dav\BeforeCreateFilePlugin;
use OCA\DAV\Events\SabrePluginAddEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use Psr\Log\LoggerInterface;

/**
 * @template-implements IEventListener<Event|SabrePluginAddEvent>
 */
class SabrePluginAddListener implements IEventListener {
	public function __construct(
		private BeforeCreateFilePlugin $beforeCreateFilePlugin,
		private LoggerInterface $logger,
	) {
	}

	#[\Override]
	public function handle(Event $event): void {
		if (!$event instanceof SabrePluginAddEvent) {
			return;
		}

		$this->logger->info('SabrePluginAddEvent fired, attaching BeforeCreateFilePlugin to Sabre server', ['app' => Application::APP_ID]);
		$event->getServer()->addPlugin($this->beforeCreateFilePlugin);
	}
}