Files
nuqloud-ai/lib/ToolProvider/NuQloudToolProvider.php

761 lines
44 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\EducAI\ToolProvider;
use DateTimeImmutable;
use OCA\EducAI\Service\ToolExecutionPolicyService;
use OCA\EducAI\Service\ToolConfirmationService;
use OCP\Calendar\ICreateFromString;
use OCP\Calendar\IManager as CalendarManager;
use OCP\Calendar\ICalendarQuery;
use OCP\Constants;
use OCP\Files\IRootFolder;
use OCP\IUserManager;
use OCP\Share\IManager as ShareManager;
use OCP\Share\IShare;
/**
* First-party tools for a managed NuQloud instance.
*
* This initial user-mode slice deliberately uses the invoking user's trusted
* server-side identity. No user credentials are accepted from a model or
* stored by this provider.
*/
class NuQloudToolProvider implements IToolProvider {
private const TOOL_FILES_LIST = 'nuqloud_files_list';
private const TOOL_FILES_READ = 'nuqloud_files_read';
private const TOOL_FILES_WRITE = 'nuqloud_files_write';
private const TOOL_FILES_COPY = 'nuqloud_files_copy';
private const TOOL_FILES_MOVE = 'nuqloud_files_move';
private const TOOL_FILES_DELETE = 'nuqloud_files_delete';
private const TOOL_FILES_SHARES_LIST = 'nuqloud_files_shares_list';
private const TOOL_FILES_SHARE_CREATE = 'nuqloud_files_share_create';
private const TOOL_FILES_SHARE_UPDATE = 'nuqloud_files_share_update';
private const TOOL_FILES_SHARE_DELETE = 'nuqloud_files_share_delete';
private const TOOL_CALENDAR_LIST = 'nuqloud_calendar_list';
private const TOOL_CALENDAR_EVENTS_LIST = 'nuqloud_calendar_events_list';
private const TOOL_CALENDAR_AVAILABILITY = 'nuqloud_calendar_availability';
private const TOOL_CALENDAR_CREATE = 'nuqloud_calendar_event_create';
private const TOOL_TALK_CREATE = 'nuqloud_talk_create_conversation';
private const TOOL_TALK_INVITE = 'nuqloud_talk_invite_user';
private const TOOL_TALK_REMOVE = 'nuqloud_talk_remove_user';
private const TOOL_TALK_ROLE = 'nuqloud_talk_set_user_role';
private const TOOL_TALK_SETTINGS = 'nuqloud_talk_update_room_settings';
/** @var array<string,mixed>|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<int,string> */
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<string,mixed> $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]; }
}