|null */ private ?array $invocationContext = null; public function __construct( private IRootFolder $rootFolder, private CalendarManager $calendarManager, private ToolExecutionPolicyService $policyService, private ShareManager $shareManager, private IUserManager $userManager, private ToolConfirmationService $confirmationService, ) { } public function getTools(): array { $tools = [ $this->tool(self::TOOL_FILES_LIST, 'List files and folders the invoking user can access at a path in their NuQloud Files.', [ 'path' => ['type' => 'string', 'description' => 'Path relative to the user Files root. Use / for the root.'], 'limit' => ['type' => 'integer', 'default' => 100, 'description' => 'Maximum entries to return (1-200).'], ], [], $this->policyService->readToolPolicy('nuqloud')), $this->tool(self::TOOL_FILES_READ, 'Read a text file the invoking user can access. Do not use this for binary files or files larger than 512 KiB.', [ 'path' => ['type' => 'string', 'description' => 'File path relative to the user Files root.'], ], ['path'], $this->policyService->readToolPolicy('nuqloud')), $this->tool(self::TOOL_FILES_WRITE, 'Create a new text file or overwrite an existing text file in the invoking user\'s NuQloud Files. Use only when the user explicitly asks to save or change a file.', [ 'path' => ['type' => 'string', 'description' => 'Destination path relative to the user Files root.'], 'content' => ['type' => 'string', 'description' => 'Complete UTF-8 file content.'], 'overwrite' => ['type' => 'boolean', 'default' => false, 'description' => 'Set true only when the user explicitly requested replacement of an existing file.'], ], ['path', 'content'], $this->writePolicy()), $this->tool(self::TOOL_FILES_COPY, 'Copy a file or folder for the invoking user without replacing an existing destination. Use only when the user explicitly asks for a copy.', [ 'source_path' => ['type' => 'string', 'description' => 'Existing source path relative to the user Files root.'], 'destination_path' => ['type' => 'string', 'description' => 'New destination path relative to the user Files root. It must not already exist.'], ], ['source_path', 'destination_path'], $this->writePolicy()), $this->tool(self::TOOL_FILES_MOVE, 'Move or rename a file or folder for the invoking user without replacing an existing destination. Use only when the user explicitly asks to move or rename it.', [ 'source_path' => ['type' => 'string', 'description' => 'Existing source path relative to the user Files root.'], 'destination_path' => ['type' => 'string', 'description' => 'New destination path relative to the user Files root. It must not already exist.'], ], ['source_path', 'destination_path'], $this->writePolicy()), $this->tool(self::TOOL_FILES_DELETE, 'Move a file or folder to the invoking user\'s NuQloud trash. Call only after the user explicitly confirms the exact path to delete.', [ 'path' => ['type' => 'string', 'description' => 'File or folder path relative to the user Files root.'], ], ['path'], $this->destructivePolicy()), $this->tool(self::TOOL_FILES_SHARES_LIST, 'List direct user shares the invoking user has created, optionally for one file or folder.', [ 'path' => ['type' => 'string', 'description' => 'Optional file or folder path relative to the user Files root.'], 'limit' => ['type' => 'integer', 'default' => 100, 'description' => 'Maximum shares to return (1-200).'], ], [], $this->policyService->readToolPolicy('nuqloud')), $this->tool(self::TOOL_FILES_SHARE_CREATE, 'Share a file or folder with one existing local NuQloud user. Public links and email shares are not available through this tool.', [ 'path' => ['type' => 'string', 'description' => 'File or folder path relative to the user Files root.'], 'recipient_user_id' => ['type' => 'string', 'description' => 'Existing local NuQloud user ID receiving the share.'], 'permission' => ['type' => 'string', 'enum' => ['read', 'edit'], 'default' => 'read', 'description' => 'Read-only or edit permission.'], ], ['path', 'recipient_user_id'], $this->writePolicy()), $this->tool(self::TOOL_FILES_SHARE_UPDATE, 'Change a direct user share created by the invoking user. Use only after the user explicitly requests the permission change.', [ 'share_id' => ['type' => 'string', 'description' => 'Share ID from nuqloud_files_shares_list.'], 'permission' => ['type' => 'string', 'enum' => ['read', 'edit'], 'description' => 'New permission.'], ], ['share_id', 'permission'], $this->writePolicy()), $this->tool(self::TOOL_FILES_SHARE_DELETE, 'Remove a direct user share created by the invoking user. Call only after the user explicitly confirms the recipient and shared item.', [ 'share_id' => ['type' => 'string', 'description' => 'Share ID from nuqloud_files_shares_list.'], ], ['share_id'], $this->destructivePolicy()), $this->tool(self::TOOL_CALENDAR_LIST, 'List the invoking user\'s writable calendars.', [], [], $this->policyService->readToolPolicy('nuqloud')), $this->tool(self::TOOL_CALENDAR_EVENTS_LIST, 'List calendar events for the invoking user in an ISO-8601 time range. Use this to identify an event before proposing an update or deletion.', [ 'calendar_uri' => ['type' => 'string', 'description' => 'Optional calendar URI from nuqloud_calendar_list.'], 'start' => ['type' => 'string', 'description' => 'Optional ISO-8601 range start with timezone; defaults to now.'], 'end' => ['type' => 'string', 'description' => 'Optional ISO-8601 range end with timezone; defaults to 30 days after start.'], 'query' => ['type' => 'string', 'description' => 'Optional title or location search phrase.'], 'limit' => ['type' => 'integer', 'default' => 50, 'description' => 'Maximum events to return (1-100).'], ], [], $this->policyService->readToolPolicy('nuqloud')), ]; if (method_exists($this->calendarManager, 'checkAvailability')) { $tools[] = $this->tool(self::TOOL_CALENDAR_AVAILABILITY, 'Check the invoking user\'s and local attendees\' availability for a proposed event. This does not create an event or send invitations.', [ 'start' => ['type' => 'string', 'description' => 'ISO-8601 proposed start datetime including timezone offset.'], 'end' => ['type' => 'string', 'description' => 'ISO-8601 proposed end datetime including timezone offset.'], 'attendees' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Optional local attendee email addresses.'], ], ['start', 'end'], $this->policyService->readToolPolicy('nuqloud')); } if (method_exists($this->calendarManager, 'createEventBuilder')) { $tools[] = $this->tool(self::TOOL_CALENDAR_CREATE, 'Create a calendar event for the invoking user. Times must include an ISO-8601 timezone offset. Do not invite attendees unless the user explicitly requests invitations.', [ 'calendar_uri' => ['type' => 'string', 'description' => 'Calendar URI from nuqloud_calendar_list. Omit only when the user has one writable calendar.'], 'summary' => ['type' => 'string', 'description' => 'Event title.'], 'start' => ['type' => 'string', 'description' => 'ISO-8601 start datetime including timezone offset.'], 'end' => ['type' => 'string', 'description' => 'ISO-8601 end datetime including timezone offset.'], 'description' => ['type' => 'string', 'description' => 'Optional event description.'], 'location' => ['type' => 'string', 'description' => 'Optional physical location or URL.'], ], ['summary', 'start', 'end'], $this->writePolicy()); } $tools[] = $this->tool(self::TOOL_TALK_CREATE, 'Create a NuQloud Talk conversation. Use only after the user explicitly confirms its name, visibility, and initial participants.', [ 'name' => ['type' => 'string', 'description' => 'Conversation name.'], 'visibility' => ['type' => 'string', 'enum' => ['private', 'public'], 'default' => 'private', 'description' => 'Private group conversation or public channel.'], 'participant_user_ids' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Optional local NuQloud users to invite.'], ], ['name'], $this->writePolicy()); $tools[] = $this->tool(self::TOOL_TALK_INVITE, 'Invite one local NuQloud user to a Talk conversation. The invoking user must be a moderator of that conversation.', [ 'room_token' => ['type' => 'string', 'description' => 'Talk conversation token; omit to use the current Talk conversation.'], 'user_id' => ['type' => 'string', 'description' => 'Existing local NuQloud user ID to invite.'], ], ['user_id'], $this->writePolicy()); $tools[] = $this->tool(self::TOOL_TALK_REMOVE, 'Remove one local NuQloud user from a Talk conversation. The invoking user must be a moderator. Never remove the invoking user or the last owner.', [ 'room_token' => ['type' => 'string', 'description' => 'Talk conversation token; omit to use the current Talk conversation.'], 'user_id' => ['type' => 'string', 'description' => 'Existing participant user ID to remove.'], ], ['user_id'], $this->destructivePolicy()); $tools[] = $this->tool(self::TOOL_TALK_ROLE, 'Promote a Talk participant to moderator or demote them to member. The invoking user must have the required Talk owner/moderator permission.', [ 'room_token' => ['type' => 'string', 'description' => 'Talk conversation token; omit to use the current Talk conversation.'], 'user_id' => ['type' => 'string', 'description' => 'Existing participant user ID.'], 'role' => ['type' => 'string', 'enum' => ['moderator', 'member'], 'description' => 'Requested Talk role.'], ], ['user_id', 'role'], $this->writePolicy()); $tools[] = $this->tool(self::TOOL_TALK_SETTINGS, 'Update Talk lobby and default session permissions. The invoking user must be a moderator. Permissions apply to regular participants, not moderators.', [ 'room_token' => ['type' => 'string', 'description' => 'Talk conversation token; omit to use the current Talk conversation.'], 'lobby_enabled' => ['type' => 'boolean', 'description' => 'Whether non-moderators wait in the lobby before joining calls.'], 'permissions' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['start_call', 'join_call', 'publish_audio', 'publish_video', 'publish_screen', 'send_messages', 'react']], 'description' => 'Complete allowed-action list for regular participants.'], ], [], $this->writePolicy()); return $tools; } public function providesTool(string $toolName): bool { return in_array($toolName, [self::TOOL_FILES_LIST, self::TOOL_FILES_READ, self::TOOL_FILES_WRITE, self::TOOL_FILES_COPY, self::TOOL_FILES_MOVE, self::TOOL_FILES_DELETE, self::TOOL_FILES_SHARES_LIST, self::TOOL_FILES_SHARE_CREATE, self::TOOL_FILES_SHARE_UPDATE, self::TOOL_FILES_SHARE_DELETE, self::TOOL_CALENDAR_LIST, self::TOOL_CALENDAR_EVENTS_LIST, self::TOOL_CALENDAR_AVAILABILITY, self::TOOL_CALENDAR_CREATE, self::TOOL_TALK_CREATE, self::TOOL_TALK_INVITE, self::TOOL_TALK_REMOVE, self::TOOL_TALK_ROLE, self::TOOL_TALK_SETTINGS], true); } public function executeTool(string $toolName, array $arguments, array $config = []): array { $userId = $this->actorUserId(); return match ($toolName) { self::TOOL_FILES_LIST => $this->listFiles($userId, $arguments), self::TOOL_FILES_READ => $this->readFile($userId, $arguments), self::TOOL_FILES_WRITE => $this->writeFile($userId, $arguments), self::TOOL_FILES_COPY => $this->copyFile($userId, $arguments), self::TOOL_FILES_MOVE => $this->moveFile($userId, $arguments), self::TOOL_FILES_DELETE => $this->deleteFile($userId, $arguments), self::TOOL_FILES_SHARES_LIST => $this->listShares($userId, $arguments), self::TOOL_FILES_SHARE_CREATE => $this->createUserShare($userId, $arguments), self::TOOL_FILES_SHARE_UPDATE => $this->updateUserShare($userId, $arguments), self::TOOL_FILES_SHARE_DELETE => $this->deleteUserShare($userId, $arguments), self::TOOL_CALENDAR_LIST => $this->listCalendars($userId), self::TOOL_CALENDAR_EVENTS_LIST => $this->listCalendarEvents($userId, $arguments), self::TOOL_CALENDAR_AVAILABILITY => $this->checkCalendarAvailability($userId, $arguments), self::TOOL_CALENDAR_CREATE => $this->createCalendarEvent($userId, $arguments), self::TOOL_TALK_CREATE => $this->createTalkConversation($userId, $arguments), self::TOOL_TALK_INVITE => $this->inviteTalkUser($userId, $arguments), self::TOOL_TALK_REMOVE => $this->removeTalkUser($userId, $arguments), self::TOOL_TALK_ROLE => $this->setTalkUserRole($userId, $arguments), self::TOOL_TALK_SETTINGS => $this->updateTalkRoomSettings($userId, $arguments), default => $this->error('Unknown NuQloud tool.'), }; } public function getToolMetadata(): array { return [ self::TOOL_FILES_LIST => ['label' => 'NuQloud Files: List Files', 'summary' => 'List files in NuQloud Files available to the invoking user.'], self::TOOL_FILES_READ => ['label' => 'NuQloud Files: Read File', 'summary' => 'Read a text file available to the invoking user in NuQloud Files.'], self::TOOL_FILES_WRITE => ['label' => 'NuQloud Files: Write File', 'summary' => 'Create or update an explicitly requested text file in NuQloud Files.'], self::TOOL_FILES_COPY => ['label' => 'NuQloud Files: Copy File', 'summary' => 'Copy a file or folder in NuQloud Files without replacing its destination.'], self::TOOL_FILES_MOVE => ['label' => 'NuQloud Files: Move File', 'summary' => 'Move or rename a file or folder in NuQloud Files without replacing its destination.'], self::TOOL_FILES_DELETE => ['label' => 'NuQloud Files: Delete File', 'summary' => 'Move an explicitly confirmed NuQloud Files item to trash.'], self::TOOL_FILES_SHARES_LIST => ['label' => 'NuQloud Files: List Shares', 'summary' => 'List direct NuQloud Files shares created by the invoking user.'], self::TOOL_FILES_SHARE_CREATE => ['label' => 'NuQloud Files: Share File', 'summary' => 'Share a NuQloud Files file or folder with one local user.'], self::TOOL_FILES_SHARE_UPDATE => ['label' => 'NuQloud Files: Update Share', 'summary' => 'Change permissions on a direct NuQloud Files share.'], self::TOOL_FILES_SHARE_DELETE => ['label' => 'NuQloud Files: Remove Share', 'summary' => 'Remove an explicitly confirmed direct NuQloud Files share.'], self::TOOL_CALENDAR_LIST => ['label' => 'NuQloud Calendar: List Calendars', 'summary' => 'List writable NuQloud calendars for the invoking user.'], self::TOOL_CALENDAR_EVENTS_LIST => ['label' => 'NuQloud Calendar: List Events', 'summary' => 'List NuQloud calendar events in a time range.'], self::TOOL_CALENDAR_AVAILABILITY => ['label' => 'NuQloud Calendar: Check Availability', 'summary' => 'Check NuQloud calendar availability without creating an event.'], self::TOOL_CALENDAR_CREATE => ['label' => 'NuQloud Calendar: Create Event', 'summary' => 'Create a NuQloud calendar event for the invoking user.'], self::TOOL_TALK_CREATE => ['label' => 'NuQloud Talk: Create Conversation', 'summary' => 'Create a NuQloud Talk conversation and optionally invite local users.'], self::TOOL_TALK_INVITE => ['label' => 'NuQloud Talk: Invite User', 'summary' => 'Invite a local user to a NuQloud Talk conversation.'], self::TOOL_TALK_REMOVE => ['label' => 'NuQloud Talk: Remove User', 'summary' => 'Remove a local user from a NuQloud Talk conversation.'], self::TOOL_TALK_ROLE => ['label' => 'NuQloud Talk: Set User Role', 'summary' => 'Promote or demote a NuQloud Talk participant.'], self::TOOL_TALK_SETTINGS => ['label' => 'NuQloud Talk: Update Settings', 'summary' => 'Update NuQloud Talk lobby and regular-participant permissions.'], ]; } public function setInvocationContext(?array $context): void { $this->invocationContext = $context; } private function listFiles(string $userId, array $arguments): array { $path = $this->normalizePath((string)($arguments['path'] ?? '/')); $limit = max(1, min(200, (int)($arguments['limit'] ?? 100))); try { $folder = $this->rootFolder->getUserFolder($userId); $node = $path === '' ? $folder : $folder->get($path); if (!$node instanceof \OCP\Files\Folder) { return $this->error('The requested path is a file, not a folder.'); } $entries = []; foreach (array_slice($node->getDirectoryListing(), 0, $limit) as $entry) { $entries[] = ['name' => $entry->getName(), 'path' => $this->relativePath($folder->getPath(), $entry->getPath()), 'type' => $entry->getType(), 'size' => $entry->getSize(), 'etag' => $entry->getEtag()]; } return $this->success(['path' => $path === '' ? '/' : $path, 'entries' => $entries]); } catch (\Throwable $e) { return $this->error('Unable to list that folder. Verify that the invoking user can access it.'); } } private function writeFile(string $userId, array $arguments): array { $path = $this->normalizePath((string)($arguments['path'] ?? '')); if ($path === '') { return $this->error('A destination path is required.'); } $content = (string)($arguments['content'] ?? ''); $overwrite = !empty($arguments['overwrite']); try { $folder = $this->rootFolder->getUserFolder($userId); if ($folder->nodeExists($path)) { if (!$overwrite) { return $this->error('The file already exists. Set overwrite only after the user explicitly approves replacement.'); } if (($confirmation = $this->requireConfirmation(self::TOOL_FILES_WRITE, $arguments)) !== null) { return $confirmation; } $node = $folder->get($path); if (!$node instanceof \OCP\Files\File) { return $this->error('The destination is a folder.'); } $node->putContent($content); return $this->success(['action' => 'updated', 'path' => $path, 'etag' => $node->getEtag()]); } $parent = dirname($path); $targetFolder = $parent === '.' ? $folder : $folder->get($parent); if (!$targetFolder instanceof \OCP\Files\Folder) { return $this->error('The destination parent folder does not exist.'); } $file = $targetFolder->newFile(basename($path)); $file->putContent($content); return $this->success(['action' => 'created', 'path' => $path, 'etag' => $file->getEtag()]); } catch (\Throwable $e) { return $this->error('Unable to write that file. Verify that the invoking user can modify the destination.'); } } private function readFile(string $userId, array $arguments): array { $path = $this->normalizePath((string)($arguments['path'] ?? '')); if ($path === '') { return $this->error('A file path is required.'); } try { $node = $this->rootFolder->getUserFolder($userId)->get($path); if (!$node instanceof \OCP\Files\File) { return $this->error('The requested path is not a file.'); } if ($node->getSize() > 524288) { return $this->error('The file is larger than the 512 KiB tool limit.'); } $content = $node->getContent(); if (!mb_check_encoding($content, 'UTF-8')) { return $this->error('The requested file is not UTF-8 text.'); } return $this->success(['path' => $path, 'etag' => $node->getEtag(), 'content' => $content]); } catch (\Throwable $e) { return $this->error('Unable to read that file. Verify that the invoking user can access it.'); } } private function copyFile(string $userId, array $arguments): array { $sourcePath = $this->normalizePath((string)($arguments['source_path'] ?? '')); $destinationPath = $this->normalizePath((string)($arguments['destination_path'] ?? '')); if ($sourcePath === '' || $destinationPath === '') { return $this->error('A source and a destination path are required.'); } try { $folder = $this->rootFolder->getUserFolder($userId); if ($folder->nodeExists($destinationPath)) { return $this->error('The destination already exists; this tool never replaces a file or folder.'); } $destinationParent = dirname($destinationPath); if ($destinationParent !== '.' && !$folder->get($destinationParent) instanceof \OCP\Files\Folder) { return $this->error('The destination parent folder does not exist.'); } $source = $folder->get($sourcePath); $source->copy($folder->getPath() . '/' . $destinationPath); return $this->success(['action' => 'copied', 'source_path' => $sourcePath, 'destination_path' => $destinationPath]); } catch (\Throwable $e) { return $this->error('Unable to copy that item. Verify that the invoking user can access the source and modify the destination.'); } } private function moveFile(string $userId, array $arguments): array { $sourcePath = $this->normalizePath((string)($arguments['source_path'] ?? '')); $destinationPath = $this->normalizePath((string)($arguments['destination_path'] ?? '')); if ($sourcePath === '' || $destinationPath === '') { return $this->error('A source and a destination path are required.'); } try { $folder = $this->rootFolder->getUserFolder($userId); if ($folder->nodeExists($destinationPath)) { return $this->error('The destination already exists; this tool never replaces a file or folder.'); } $destinationParent = dirname($destinationPath); if ($destinationParent !== '.' && !$folder->get($destinationParent) instanceof \OCP\Files\Folder) { return $this->error('The destination parent folder does not exist.'); } $folder->get($sourcePath)->move($folder->getPath() . '/' . $destinationPath); return $this->success(['action' => 'moved', 'source_path' => $sourcePath, 'destination_path' => $destinationPath]); } catch (\Throwable $e) { return $this->error('Unable to move that item. Verify that the invoking user can modify both locations.'); } } private function deleteFile(string $userId, array $arguments): array { $path = $this->normalizePath((string)($arguments['path'] ?? '')); if ($path === '') { return $this->error('A file or folder path is required.'); } if (($confirmation = $this->requireConfirmation(self::TOOL_FILES_DELETE, $arguments)) !== null) { return $confirmation; } try { $this->rootFolder->getUserFolder($userId)->get($path)->delete(); return $this->success(['action' => 'deleted', 'path' => $path]); } catch (\Throwable $e) { return $this->error('Unable to delete that item. Verify that the invoking user can modify it.'); } } private function listShares(string $userId, array $arguments): array { try { $path = $this->normalizePath((string)($arguments['path'] ?? '')); $node = $path === '' ? null : $this->rootFolder->getUserFolder($userId)->get($path); $limit = max(1, min(200, (int)($arguments['limit'] ?? 100))); $shares = $this->shareManager->getSharesBy($userId, IShare::TYPE_USER, $node, false, $limit); $rootPath = $this->rootFolder->getUserFolder($userId)->getPath(); return $this->success(['shares' => array_map(fn (IShare $share): array => $this->shareData($share, $rootPath), $shares)]); } catch (\Throwable $e) { return $this->error('Unable to list file shares. Verify that the invoking user can access the requested item.'); } } private function createUserShare(string $userId, array $arguments): array { $path = $this->normalizePath((string)($arguments['path'] ?? '')); $recipient = trim((string)($arguments['recipient_user_id'] ?? '')); if ($path === '' || $recipient === '') { return $this->error('A file path and recipient user ID are required.'); } if ($recipient === $userId || $this->userManager->get($recipient) === null) { return $this->error('The recipient must be a different existing local NuQloud user.'); } try { if (!$this->shareManager->shareApiEnabled()) { return $this->error('File sharing is disabled by this NuQloud instance.'); } $node = $this->rootFolder->getUserFolder($userId)->get($path); $share = $this->shareManager->newShare(); $share->setNode($node) ->setShareType(IShare::TYPE_USER) ->setSharedWith($recipient) ->setSharedBy($userId) ->setShareOwner($userId) ->setPermissions($this->sharePermissions((string)($arguments['permission'] ?? 'read'))); return $this->success(['action' => 'created', 'share' => $this->shareData($this->shareManager->createShare($share), $this->rootFolder->getUserFolder($userId)->getPath())]); } catch (\Throwable $e) { return $this->error('Unable to create that share. The user may lack permission to share this item.'); } } private function updateUserShare(string $userId, array $arguments): array { $shareId = trim((string)($arguments['share_id'] ?? '')); if ($shareId === '') { return $this->error('A share ID is required.'); } if ((string)($arguments['permission'] ?? '') === 'edit' && ($confirmation = $this->requireConfirmation(self::TOOL_FILES_SHARE_UPDATE, $arguments)) !== null) { return $confirmation; } try { $share = $this->shareManager->getShareById($shareId); if ($share->getShareType() !== IShare::TYPE_USER || $share->getSharedBy() !== $userId) { return $this->error('Only direct user shares created by the invoking user can be changed.'); } $share->setPermissions($this->sharePermissions((string)($arguments['permission'] ?? ''))); return $this->success(['action' => 'updated', 'share' => $this->shareData($this->shareManager->updateShare($share), $this->rootFolder->getUserFolder($userId)->getPath())]); } catch (\Throwable $e) { return $this->error('Unable to update that share.'); } } private function deleteUserShare(string $userId, array $arguments): array { $shareId = trim((string)($arguments['share_id'] ?? '')); if ($shareId === '') { return $this->error('A share ID is required.'); } if (($confirmation = $this->requireConfirmation(self::TOOL_FILES_SHARE_DELETE, $arguments)) !== null) { return $confirmation; } try { $share = $this->shareManager->getShareById($shareId); if ($share->getShareType() !== IShare::TYPE_USER || $share->getSharedBy() !== $userId) { return $this->error('Only direct user shares created by the invoking user can be removed.'); } $this->shareManager->deleteShare($share); return $this->success(['action' => 'removed', 'share_id' => $shareId]); } catch (\Throwable $e) { return $this->error('Unable to remove that share.'); } } private function sharePermissions(string $permission): int { return match ($permission) { 'read' => Constants::PERMISSION_READ, 'edit' => Constants::PERMISSION_READ | Constants::PERMISSION_UPDATE | Constants::PERMISSION_CREATE | Constants::PERMISSION_DELETE, default => throw new \InvalidArgumentException('Invalid share permission.'), }; } private function shareData(IShare $share, string $rootPath): array { return [ 'id' => $share->getId(), 'path' => $this->relativePath($rootPath, $share->getNode()->getPath()), 'recipient_user_id' => $share->getSharedWith(), 'permissions' => $share->getPermissions(), ]; } private function listCalendars(string $userId): array { $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId); $result = []; foreach ($calendars as $calendar) { if ($calendar instanceof ICreateFromString) { $result[] = ['uri' => $calendar->getUri(), 'name' => $calendar->getDisplayName(), 'writable' => true]; } } return $this->success(['calendars' => $result]); } private function listCalendarEvents(string $userId, array $arguments): array { try { $start = isset($arguments['start']) ? new DateTimeImmutable((string)$arguments['start']) : new DateTimeImmutable(); $end = isset($arguments['end']) ? new DateTimeImmutable((string)$arguments['end']) : $start->modify('+30 days'); if ($end <= $start) { return $this->error('The calendar range end must be after its start.'); } $query = $this->calendarManager->newQuery('principals/users/' . $userId); $calendarUri = trim((string)($arguments['calendar_uri'] ?? '')); if ($calendarUri !== '') { $query->addSearchCalendar($calendarUri); } $pattern = trim((string)($arguments['query'] ?? '')); if ($pattern !== '') { $query->setSearchPattern($pattern); $query->addSearchProperty(ICalendarQuery::SEARCH_PROPERTY_SUMMARY); $query->addSearchProperty(ICalendarQuery::SEARCH_PROPERTY_LOCATION); } $query->addType('VEVENT'); $query->setTimerangeStart($start); $query->setTimerangeEnd($end); $query->setLimit(max(1, min(100, (int)($arguments['limit'] ?? 50)))); return $this->success(['events' => $this->calendarManager->searchForPrincipal($query)]); } catch (\Throwable $e) { return $this->error('Unable to list calendar events. Use ISO-8601 dates with a timezone.'); } } private function checkCalendarAvailability(string $userId, array $arguments): array { try { if (!method_exists($this->calendarManager, 'checkAvailability')) { return $this->error('Availability checks are not supported by this NuQloud server.'); } $start = new DateTimeImmutable((string)($arguments['start'] ?? '')); $end = new DateTimeImmutable((string)($arguments['end'] ?? '')); if ($end <= $start) { return $this->error('The availability range end must be after its start.'); } $user = $this->userManager->get($userId); if ($user === null) { return $this->error('The invoking user is no longer available.'); } $attendees = isset($arguments['attendees']) && is_array($arguments['attendees']) ? array_values(array_filter($arguments['attendees'], static fn ($email): bool => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL) !== false)) : []; $results = $this->calendarManager->checkAvailability($start, $end, $user, $attendees); return $this->success(['availability' => array_map(static fn ($result): array => [ 'attendee_email' => $result->getAttendeeEmail(), 'available' => $result->isAvailable(), ], $results)]); } catch (\Throwable $e) { return $this->error('Unable to check availability. Use ISO-8601 dates with a timezone.'); } } private function createCalendarEvent(string $userId, array $arguments): array { try { if (!method_exists($this->calendarManager, 'createEventBuilder')) { return $this->error('Calendar event creation is not supported by this NuQloud server.'); } $start = new DateTimeImmutable((string)($arguments['start'] ?? '')); $end = new DateTimeImmutable((string)($arguments['end'] ?? '')); if ($end <= $start) { return $this->error('The event end must be after its start.'); } $calendar = $this->resolveCalendar($userId, (string)($arguments['calendar_uri'] ?? '')); if (!$calendar instanceof ICreateFromString) { return $this->error('No writable calendar was found.'); } $summary = trim((string)($arguments['summary'] ?? '')); if ($summary === '') { return $this->error('An event summary is required.'); } $builder = $this->calendarManager->createEventBuilder(); $builder->setSummary($summary)->setStartDate($start)->setEndDate($end); if (isset($arguments['description'])) { $builder->setDescription((string)$arguments['description']); } if (isset($arguments['location'])) { $builder->setLocation((string)$arguments['location']); } $name = $builder->createInCalendar($calendar); return $this->success(['action' => 'created', 'calendar_uri' => $calendar->getUri(), 'event_file' => $name]); } catch (\Throwable $e) { return $this->error('Unable to create the calendar event. Use ISO-8601 dates with a timezone and a writable calendar.'); } } private function resolveCalendar(string $userId, string $uri): ?ICreateFromString { $matches = []; foreach ($this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId) as $calendar) { if ($calendar instanceof ICreateFromString && ($uri === '' || $calendar->getUri() === $uri)) { $matches[] = $calendar; } } return count($matches) === 1 ? $matches[0] : null; } private function createTalkConversation(string $userId, array $arguments): array { if (($confirmation = $this->requireConfirmation(self::TOOL_TALK_CREATE, $arguments)) !== null) { return $confirmation; } $name = trim((string)($arguments['name'] ?? '')); $owner = $this->userManager->get($userId); if ($name === '' || !$owner instanceof \OCP\IUser) { return $this->error('A conversation name and a valid invoking user are required.'); } try { $services = $this->talkServices(); $type = ($arguments['visibility'] ?? 'private') === 'public' ? \OCA\Talk\Room::TYPE_PUBLIC : \OCA\Talk\Room::TYPE_GROUP; $room = $services['roomService']->createConversation($type, $name, $owner); $participants = $this->talkUserIds($arguments['participant_user_ids'] ?? []); $this->addTalkUsers($room, $owner, $participants, $services['participantService']); return $this->success(['action' => 'created', 'room_token' => $room->getToken(), 'name' => $room->getName(), 'visibility' => $type === \OCA\Talk\Room::TYPE_PUBLIC ? 'public' : 'private', 'invited_user_ids' => $participants]); } catch (\Throwable $e) { return $this->error('Unable to create the Talk conversation. Verify that Talk is enabled and the requested users exist.'); } } private function inviteTalkUser(string $userId, array $arguments): array { if (($confirmation = $this->requireConfirmation(self::TOOL_TALK_INVITE, $arguments)) !== null) { return $confirmation; } try { $services = $this->talkServices(); [$room, $actor] = $this->talkRoomAndModerator($services, $userId, $arguments); $target = trim((string)($arguments['user_id'] ?? '')); $this->addTalkUsers($room, $this->userManager->get($userId), [$target], $services['participantService']); return $this->success(['action' => 'invited', 'room_token' => $room->getToken(), 'user_id' => $target]); } catch (\Throwable $e) { return $this->error('Unable to invite that user. The invoking user must be a Talk moderator and the target must be an existing local user.'); } } private function removeTalkUser(string $userId, array $arguments): array { if (($confirmation = $this->requireConfirmation(self::TOOL_TALK_REMOVE, $arguments)) !== null) { return $confirmation; } try { $services = $this->talkServices(); [$room, $actor] = $this->talkRoomAndModerator($services, $userId, $arguments); $targetId = trim((string)($arguments['user_id'] ?? '')); if ($targetId === '' || $targetId === $userId) { return $this->error('The invoking user cannot remove themselves with this tool.'); } $target = $services['participantService']->getParticipantByActor($room, \OCA\Talk\Model\Attendee::ACTOR_USERS, $targetId); if ($target->isOwner()) { return $this->error('Owners cannot be removed with this tool. Transfer ownership in Talk first.'); } $services['participantService']->removeAttendee($room, $target, \OCA\Talk\Events\AAttendeeRemovedEvent::REASON_REMOVED); return $this->success(['action' => 'removed', 'room_token' => $room->getToken(), 'user_id' => $targetId]); } catch (\Throwable $e) { return $this->error('Unable to remove that user. The invoking user must be a Talk moderator and the target must be a removable local participant.'); } } private function setTalkUserRole(string $userId, array $arguments): array { if (($confirmation = $this->requireConfirmation(self::TOOL_TALK_ROLE, $arguments)) !== null) { return $confirmation; } try { $services = $this->talkServices(); [$room, $actor] = $this->talkRoomAndModerator($services, $userId, $arguments); $target = $services['participantService']->getParticipantByActor($room, \OCA\Talk\Model\Attendee::ACTOR_USERS, trim((string)($arguments['user_id'] ?? ''))); $role = (string)($arguments['role'] ?? ''); if (!in_array($role, ['moderator', 'member'], true)) { return $this->error('Role must be moderator or member.'); } $services['participantService']->updateParticipantTypeByModerator($room, $actor, $target, $role === 'moderator', $role === 'moderator' ? \OCA\Talk\Participant::MODERATOR : \OCA\Talk\Participant::USER); return $this->success(['action' => 'role_updated', 'room_token' => $room->getToken(), 'user_id' => trim((string)$arguments['user_id']), 'role' => $role]); } catch (\Throwable $e) { return $this->error('Unable to update that participant role. Talk only permits role changes allowed to the invoking owner or moderator.'); } } private function updateTalkRoomSettings(string $userId, array $arguments): array { if (($confirmation = $this->requireConfirmation(self::TOOL_TALK_SETTINGS, $arguments)) !== null) { return $confirmation; } try { $services = $this->talkServices(); [$room] = $this->talkRoomAndModerator($services, $userId, $arguments); if (array_key_exists('lobby_enabled', $arguments)) { $services['roomService']->setLobby($room, !empty($arguments['lobby_enabled']) ? \OCA\Talk\Webinary::LOBBY_NON_MODERATORS : \OCA\Talk\Webinary::LOBBY_NONE, null); } if (array_key_exists('permissions', $arguments)) { $services['roomService']->setDefaultPermissions($room, $this->talkPermissions($arguments['permissions'])); } return $this->success(['action' => 'settings_updated', 'room_token' => $room->getToken()]); } catch (\Throwable $e) { return $this->error('Unable to update Talk settings. The invoking user must be a moderator and the requested settings must be valid for this conversation.'); } } /** @return array{manager:object,participantService:object,roomService:object} */ private function talkServices(): array { if (!class_exists(\OCA\Talk\Manager::class)) { throw new \RuntimeException('NuQloud Talk is not enabled.'); } return [ 'manager' => \OC::$server->get(\OCA\Talk\Manager::class), 'participantService' => \OC::$server->get(\OCA\Talk\Service\ParticipantService::class), 'roomService' => \OC::$server->get(\OCA\Talk\Service\RoomService::class), ]; } /** @param array{manager:object,participantService:object,roomService:object} $services @return array{0:object,1:object} */ private function talkRoomAndModerator(array $services, string $userId, array $arguments): array { $token = trim((string)($arguments['room_token'] ?? ($this->invocationContext['room_token'] ?? ''))); if ($token === '') { throw new \InvalidArgumentException('A Talk conversation token is required outside Talk.'); } $room = $services['manager']->getRoomByToken($token); $actor = $services['participantService']->getParticipantByActor($room, \OCA\Talk\Model\Attendee::ACTOR_USERS, $userId); if (!$actor->hasModeratorPermissions(false)) { throw new \RuntimeException('The invoking user is not a Talk moderator.'); } return [$room, $actor]; } /** @param mixed $value @return array */ private function talkUserIds(mixed $value): array { if (!is_array($value)) { return []; } return array_values(array_unique(array_filter(array_map(static fn ($id): string => trim((string)$id), $value), static fn (string $id): bool => $id !== ''))); } private function addTalkUsers(object $room, ?\OCP\IUser $actor, array $userIds, object $participantService): void { if (!$actor instanceof \OCP\IUser || $userIds === []) { return; } $participants = []; foreach ($userIds as $userId) { $user = $this->userManager->get($userId); if (!$user instanceof \OCP\IUser) { throw new \InvalidArgumentException('Unknown local user.'); } $participants[] = ['actorType' => \OCA\Talk\Model\Attendee::ACTOR_USERS, 'actorId' => $user->getUID(), 'displayName' => $user->getDisplayName()]; } $participantService->addUsers($room, $participants, $actor); } /** @param mixed $value */ private function talkPermissions(mixed $value): int { if (!is_array($value)) { throw new \InvalidArgumentException('permissions must be an array.'); } $map = ['start_call' => \OCA\Talk\Model\Attendee::PERMISSIONS_CALL_START, 'join_call' => \OCA\Talk\Model\Attendee::PERMISSIONS_CALL_JOIN, 'publish_audio' => \OCA\Talk\Model\Attendee::PERMISSIONS_PUBLISH_AUDIO, 'publish_video' => \OCA\Talk\Model\Attendee::PERMISSIONS_PUBLISH_VIDEO, 'publish_screen' => \OCA\Talk\Model\Attendee::PERMISSIONS_PUBLISH_SCREEN, 'send_messages' => \OCA\Talk\Model\Attendee::PERMISSIONS_CHAT, 'react' => \OCA\Talk\Model\Attendee::PERMISSIONS_REACT]; $permissions = 0; foreach ($value as $permission) { if (!is_string($permission) || !isset($map[$permission])) { throw new \InvalidArgumentException('Unknown Talk permission.'); } $permissions |= $map[$permission]; } return $permissions; } private function actorUserId(): string { $userId = $this->invocationContext['user_id'] ?? null; if (!is_string($userId) || $userId === '') { throw new \RuntimeException('No trusted invoking user is available.'); } return str_starts_with($userId, 'users/') ? substr($userId, strlen('users/')) : $userId; } private function normalizePath(string $path): string { $path = trim($path); $path = ltrim($path, '/'); if ($path === '') { return ''; } if (str_contains($path, "\0") || preg_match('#(^|/)\.\.(/|$)#', $path)) { throw new \InvalidArgumentException('Invalid file path.'); } return $path; } private function relativePath(string $rootPath, string $path): string { $prefix = rtrim($rootPath, '/') . '/'; return str_starts_with($path, $prefix) ? substr($path, strlen($prefix)) : $path; } private function tool(string $name, string $description, array $properties, array $required, array $policy): array { return ['name' => $name, 'description' => $description, 'schema' => ['type' => 'object', 'properties' => $properties === [] ? new \stdClass() : $properties, 'required' => $required], 'policy' => $policy, 'label' => $this->getToolMetadata()[$name]['label'], 'summary' => $this->getToolMetadata()[$name]['summary']]; } private function writePolicy(): array { return ['kind' => ToolExecutionPolicyService::KIND_WRITE, 'read_only' => false, 'idempotent' => false, 'destructive' => false, 'loop_threshold' => ToolExecutionPolicyService::MUTATING_TOOL_LOOP_THRESHOLD, 'source' => 'nuqloud']; } private function destructivePolicy(): array { return ['kind' => ToolExecutionPolicyService::KIND_WRITE, 'read_only' => false, 'idempotent' => false, 'destructive' => true, 'loop_threshold' => ToolExecutionPolicyService::MUTATING_TOOL_LOOP_THRESHOLD, 'source' => 'nuqloud']; } /** @param array $arguments */ private function requireConfirmation(string $toolName, array $arguments): ?array { if ($this->confirmationService->consume($toolName, $arguments, $this->invocationContext)) { return null; } $pending = $this->confirmationService->request($toolName, $arguments, $this->invocationContext); return $this->error('Confirmation required. Summarize the requested action and ask the user for a brief affirmative reply, for example yes. This confirmation expires in ' . $pending['expires_in_seconds'] . ' seconds.'); } private function success(array $data): array { return ['content' => [['type' => 'text', 'text' => json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)]], 'isError' => false]; } private function error(string $message): array { return ['content' => [['type' => 'text', 'text' => $message]], 'isError' => true]; } }