diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index d4f0b775b1c6acda17d69c4a0094cbb5efbb2134..6dbfc45b7b30ba669bcf4f2606e82c29480d6c17 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -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); } diff --git a/lib/Dav/BeforeCreateFilePlugin.php b/lib/Dav/BeforeCreateFilePlugin.php new file mode 100644 index 0000000000000000000000000000000000000000..43c69ea53d8aa3d8b29a54f11468b08c3b3c1c40 --- /dev/null +++ b/lib/Dav/BeforeCreateFilePlugin.php @@ -0,0 +1,225 @@ +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 7 is an one time conversation, matching the + // frontend's createTalkRoom in src/services/talkService.js. + $room = $roomService->createConversation( + 7, + $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; + } + } +} diff --git a/lib/Listener/SabrePluginAddListener.php b/lib/Listener/SabrePluginAddListener.php new file mode 100644 index 0000000000000000000000000000000000000000..711c447df813f69a76960c201bcf1ddde92192ed --- /dev/null +++ b/lib/Listener/SabrePluginAddListener.php @@ -0,0 +1,38 @@ + + */ +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); + } +} diff --git a/src/components/Editor/Invitees/InviteesList.vue b/src/components/Editor/Invitees/InviteesList.vue index 3fc2ffa7279e41e70c8c553fcd88fb7b4c91bbeb..d61f59dab96efc85deb95df2fb14418379dddd73 100644 --- a/src/components/Editor/Invitees/InviteesList.vue +++ b/src/components/Editor/Invitees/InviteesList.vue @@ -379,6 +379,18 @@ export default { member, }) this.recentAttendees.push(email) + this.addConversationLink() + }, + addConversationLink() { + if (!this.calendarObjectInstance.attendees || this.calendarObjectInstance.attendees.length < 1) { + return + } + + if (typeof this.calendarObjectInstance.location === 'string' && this.calendarObjectInstance.location.trim() !== '') { + return + } + + this.createTalkRoom() }, removeAttendee(attendee) { // Remove attendee from participating group diff --git a/src/services/talkService.js b/src/services/talkService.js index 4d255cd6915adbb95a63992edb28d2846e8df7e9..ecc83ce1fcb28cdd9920d0a170f53325d5786717 100644 --- a/src/services/talkService.js +++ b/src/services/talkService.js @@ -28,7 +28,7 @@ export async function createTalkRoom(eventTitle = null, eventDescription = null, try { response = await HTTPClient.post(generateOcsUrl('apps/spreed/api/' + apiVersion + '/', 2) + 'room', { - roomType: 3, + roomType: 7, roomName: eventTitle || t('calendar', 'Talk conversation for event'), objectType: 'event', objectId: md5(new Date()),