You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1687 lines
62 KiB

  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OC\TaskProcessing;
  8. use GuzzleHttp\Exception\ClientException;
  9. use GuzzleHttp\Exception\ServerException;
  10. use OC\AppFramework\Bootstrap\Coordinator;
  11. use OC\Files\SimpleFS\SimpleFile;
  12. use OC\TaskProcessing\Db\TaskMapper;
  13. use OCP\App\IAppManager;
  14. use OCP\AppFramework\Db\DoesNotExistException;
  15. use OCP\AppFramework\Db\MultipleObjectsReturnedException;
  16. use OCP\BackgroundJob\IJobList;
  17. use OCP\DB\Exception;
  18. use OCP\EventDispatcher\IEventDispatcher;
  19. use OCP\Files\AppData\IAppDataFactory;
  20. use OCP\Files\Config\IUserMountCache;
  21. use OCP\Files\File;
  22. use OCP\Files\GenericFileException;
  23. use OCP\Files\IAppData;
  24. use OCP\Files\InvalidPathException;
  25. use OCP\Files\IRootFolder;
  26. use OCP\Files\Node;
  27. use OCP\Files\NotPermittedException;
  28. use OCP\Files\SimpleFS\ISimpleFile;
  29. use OCP\Files\SimpleFS\ISimpleFolder;
  30. use OCP\Http\Client\IClientService;
  31. use OCP\IAppConfig;
  32. use OCP\ICache;
  33. use OCP\ICacheFactory;
  34. use OCP\IL10N;
  35. use OCP\IServerContainer;
  36. use OCP\IUserManager;
  37. use OCP\IUserSession;
  38. use OCP\L10N\IFactory;
  39. use OCP\Lock\LockedException;
  40. use OCP\SpeechToText\ISpeechToTextProvider;
  41. use OCP\SpeechToText\ISpeechToTextProviderWithId;
  42. use OCP\TaskProcessing\EShapeType;
  43. use OCP\TaskProcessing\Events\GetTaskProcessingProvidersEvent;
  44. use OCP\TaskProcessing\Events\TaskFailedEvent;
  45. use OCP\TaskProcessing\Events\TaskSuccessfulEvent;
  46. use OCP\TaskProcessing\Exception\NotFoundException;
  47. use OCP\TaskProcessing\Exception\ProcessingException;
  48. use OCP\TaskProcessing\Exception\UnauthorizedException;
  49. use OCP\TaskProcessing\Exception\ValidationException;
  50. use OCP\TaskProcessing\IInternalTaskType;
  51. use OCP\TaskProcessing\IManager;
  52. use OCP\TaskProcessing\IProvider;
  53. use OCP\TaskProcessing\ISynchronousProvider;
  54. use OCP\TaskProcessing\ITaskType;
  55. use OCP\TaskProcessing\ITriggerableProvider;
  56. use OCP\TaskProcessing\ShapeDescriptor;
  57. use OCP\TaskProcessing\ShapeEnumValue;
  58. use OCP\TaskProcessing\Task;
  59. use OCP\TaskProcessing\TaskTypes\AudioToText;
  60. use OCP\TaskProcessing\TaskTypes\TextToImage;
  61. use OCP\TaskProcessing\TaskTypes\TextToText;
  62. use OCP\TaskProcessing\TaskTypes\TextToTextHeadline;
  63. use OCP\TaskProcessing\TaskTypes\TextToTextSummary;
  64. use OCP\TaskProcessing\TaskTypes\TextToTextTopics;
  65. use Psr\Container\ContainerExceptionInterface;
  66. use Psr\Container\NotFoundExceptionInterface;
  67. use Psr\Log\LoggerInterface;
  68. class Manager implements IManager {
  69. public const LEGACY_PREFIX_TEXTPROCESSING = 'legacy:TextProcessing:';
  70. public const LEGACY_PREFIX_TEXTTOIMAGE = 'legacy:TextToImage:';
  71. public const LEGACY_PREFIX_SPEECHTOTEXT = 'legacy:SpeechToText:';
  72. public const LAZY_CONFIG_KEYS = [
  73. 'ai.taskprocessing_type_preferences',
  74. 'ai.taskprocessing_provider_preferences',
  75. ];
  76. public const MAX_TASK_AGE_SECONDS = 60 * 60 * 24 * 31 * 6; // 6 months
  77. private const TASK_TYPES_CACHE_KEY = 'available_task_types_v3';
  78. private const TASK_TYPE_IDS_CACHE_KEY = 'available_task_type_ids';
  79. /** @var list<IProvider>|null */
  80. private ?array $providers = null;
  81. /**
  82. * @var array<array-key,array{name: string, description: string, inputShape: ShapeDescriptor[], inputShapeEnumValues: ShapeEnumValue[][], inputShapeDefaults: array<array-key, numeric|string>, isInternal: bool, optionalInputShape: ShapeDescriptor[], optionalInputShapeEnumValues: ShapeEnumValue[][], optionalInputShapeDefaults: array<array-key, numeric|string>, outputShape: ShapeDescriptor[], outputShapeEnumValues: ShapeEnumValue[][], optionalOutputShape: ShapeDescriptor[], optionalOutputShapeEnumValues: ShapeEnumValue[][]}>
  83. */
  84. private ?array $availableTaskTypes = null;
  85. /** @var list<string>|null */
  86. private ?array $availableTaskTypeIds = null;
  87. private IAppData $appData;
  88. private ?array $preferences = null;
  89. private ?array $providersById = null;
  90. /** @var ITaskType[]|null */
  91. private ?array $taskTypes = null;
  92. private ICache $distributedCache;
  93. private ?GetTaskProcessingProvidersEvent $eventResult = null;
  94. public function __construct(
  95. private IAppConfig $appConfig,
  96. private Coordinator $coordinator,
  97. private IServerContainer $serverContainer,
  98. private LoggerInterface $logger,
  99. private TaskMapper $taskMapper,
  100. private IJobList $jobList,
  101. private IEventDispatcher $dispatcher,
  102. IAppDataFactory $appDataFactory,
  103. private IRootFolder $rootFolder,
  104. private \OCP\TextToImage\IManager $textToImageManager,
  105. private IUserMountCache $userMountCache,
  106. private IClientService $clientService,
  107. private IAppManager $appManager,
  108. private IUserManager $userManager,
  109. private IUserSession $userSession,
  110. ICacheFactory $cacheFactory,
  111. private IFactory $l10nFactory,
  112. ) {
  113. $this->appData = $appDataFactory->get('core');
  114. $this->distributedCache = $cacheFactory->createDistributed('task_processing::');
  115. }
  116. /**
  117. * This is almost a copy of textProcessingManager->getProviders
  118. * to avoid a dependency cycle between TextProcessingManager and TaskProcessingManager
  119. */
  120. private function _getRawTextProcessingProviders(): array {
  121. $context = $this->coordinator->getRegistrationContext();
  122. if ($context === null) {
  123. return [];
  124. }
  125. $providers = [];
  126. foreach ($context->getTextProcessingProviders() as $providerServiceRegistration) {
  127. $class = $providerServiceRegistration->getService();
  128. try {
  129. $providers[$class] = $this->serverContainer->get($class);
  130. } catch (\Throwable $e) {
  131. $this->logger->error('Failed to load Text processing provider ' . $class, [
  132. 'exception' => $e,
  133. ]);
  134. }
  135. }
  136. return $providers;
  137. }
  138. private function _getTextProcessingProviders(): array {
  139. $oldProviders = $this->_getRawTextProcessingProviders();
  140. $newProviders = [];
  141. foreach ($oldProviders as $oldProvider) {
  142. $provider = new class($oldProvider) implements IProvider, ISynchronousProvider {
  143. private \OCP\TextProcessing\IProvider $provider;
  144. public function __construct(\OCP\TextProcessing\IProvider $provider) {
  145. $this->provider = $provider;
  146. }
  147. public function getId(): string {
  148. if ($this->provider instanceof \OCP\TextProcessing\IProviderWithId) {
  149. return $this->provider->getId();
  150. }
  151. return Manager::LEGACY_PREFIX_TEXTPROCESSING . $this->provider::class;
  152. }
  153. public function getName(): string {
  154. return $this->provider->getName();
  155. }
  156. public function getTaskTypeId(): string {
  157. return match ($this->provider->getTaskType()) {
  158. \OCP\TextProcessing\FreePromptTaskType::class => TextToText::ID,
  159. \OCP\TextProcessing\HeadlineTaskType::class => TextToTextHeadline::ID,
  160. \OCP\TextProcessing\TopicsTaskType::class => TextToTextTopics::ID,
  161. \OCP\TextProcessing\SummaryTaskType::class => TextToTextSummary::ID,
  162. default => Manager::LEGACY_PREFIX_TEXTPROCESSING . $this->provider->getTaskType(),
  163. };
  164. }
  165. public function getExpectedRuntime(): int {
  166. if ($this->provider instanceof \OCP\TextProcessing\IProviderWithExpectedRuntime) {
  167. return $this->provider->getExpectedRuntime();
  168. }
  169. return 60;
  170. }
  171. public function getOptionalInputShape(): array {
  172. return [];
  173. }
  174. public function getOptionalOutputShape(): array {
  175. return [];
  176. }
  177. public function process(?string $userId, array $input, callable $reportProgress): array {
  178. if ($this->provider instanceof \OCP\TextProcessing\IProviderWithUserId) {
  179. $this->provider->setUserId($userId);
  180. }
  181. try {
  182. return ['output' => $this->provider->process($input['input'])];
  183. } catch (\RuntimeException $e) {
  184. throw new ProcessingException($e->getMessage(), 0, $e);
  185. }
  186. }
  187. public function getInputShapeEnumValues(): array {
  188. return [];
  189. }
  190. public function getInputShapeDefaults(): array {
  191. return [];
  192. }
  193. public function getOptionalInputShapeEnumValues(): array {
  194. return [];
  195. }
  196. public function getOptionalInputShapeDefaults(): array {
  197. return [];
  198. }
  199. public function getOutputShapeEnumValues(): array {
  200. return [];
  201. }
  202. public function getOptionalOutputShapeEnumValues(): array {
  203. return [];
  204. }
  205. };
  206. $newProviders[$provider->getId()] = $provider;
  207. }
  208. return $newProviders;
  209. }
  210. /**
  211. * @return ITaskType[]
  212. */
  213. private function _getTextProcessingTaskTypes(): array {
  214. $oldProviders = $this->_getRawTextProcessingProviders();
  215. $newTaskTypes = [];
  216. foreach ($oldProviders as $oldProvider) {
  217. // These are already implemented in the TaskProcessing realm
  218. if (in_array($oldProvider->getTaskType(), [
  219. \OCP\TextProcessing\FreePromptTaskType::class,
  220. \OCP\TextProcessing\HeadlineTaskType::class,
  221. \OCP\TextProcessing\TopicsTaskType::class,
  222. \OCP\TextProcessing\SummaryTaskType::class
  223. ], true)) {
  224. continue;
  225. }
  226. $taskType = new class($oldProvider->getTaskType()) implements ITaskType {
  227. private string $oldTaskTypeClass;
  228. private \OCP\TextProcessing\ITaskType $oldTaskType;
  229. private IL10N $l;
  230. public function __construct(string $oldTaskTypeClass) {
  231. $this->oldTaskTypeClass = $oldTaskTypeClass;
  232. $this->oldTaskType = \OCP\Server::get($oldTaskTypeClass);
  233. $this->l = \OCP\Server::get(IFactory::class)->get('core');
  234. }
  235. public function getId(): string {
  236. return Manager::LEGACY_PREFIX_TEXTPROCESSING . $this->oldTaskTypeClass;
  237. }
  238. public function getName(): string {
  239. return $this->oldTaskType->getName();
  240. }
  241. public function getDescription(): string {
  242. return $this->oldTaskType->getDescription();
  243. }
  244. public function getInputShape(): array {
  245. return ['input' => new ShapeDescriptor($this->l->t('Input text'), $this->l->t('The input text'), EShapeType::Text)];
  246. }
  247. public function getOutputShape(): array {
  248. return ['output' => new ShapeDescriptor($this->l->t('Input text'), $this->l->t('The input text'), EShapeType::Text)];
  249. }
  250. };
  251. $newTaskTypes[$taskType->getId()] = $taskType;
  252. }
  253. return $newTaskTypes;
  254. }
  255. /**
  256. * @return IProvider[]
  257. */
  258. private function _getTextToImageProviders(): array {
  259. $oldProviders = $this->textToImageManager->getProviders();
  260. $newProviders = [];
  261. foreach ($oldProviders as $oldProvider) {
  262. $newProvider = new class($oldProvider, $this->appData) implements IProvider, ISynchronousProvider {
  263. private \OCP\TextToImage\IProvider $provider;
  264. private IAppData $appData;
  265. public function __construct(\OCP\TextToImage\IProvider $provider, IAppData $appData) {
  266. $this->provider = $provider;
  267. $this->appData = $appData;
  268. }
  269. public function getId(): string {
  270. return Manager::LEGACY_PREFIX_TEXTTOIMAGE . $this->provider->getId();
  271. }
  272. public function getName(): string {
  273. return $this->provider->getName();
  274. }
  275. public function getTaskTypeId(): string {
  276. return TextToImage::ID;
  277. }
  278. public function getExpectedRuntime(): int {
  279. return $this->provider->getExpectedRuntime();
  280. }
  281. public function getOptionalInputShape(): array {
  282. return [];
  283. }
  284. public function getOptionalOutputShape(): array {
  285. return [];
  286. }
  287. public function process(?string $userId, array $input, callable $reportProgress): array {
  288. try {
  289. $folder = $this->appData->getFolder('text2image');
  290. } catch (\OCP\Files\NotFoundException) {
  291. $folder = $this->appData->newFolder('text2image');
  292. }
  293. $resources = [];
  294. $files = [];
  295. for ($i = 0; $i < $input['numberOfImages']; $i++) {
  296. $file = $folder->newFile(time() . '-' . rand(1, 100000) . '-' . $i);
  297. $files[] = $file;
  298. $resource = $file->write();
  299. if ($resource !== false && $resource !== true && is_resource($resource)) {
  300. $resources[] = $resource;
  301. } else {
  302. throw new ProcessingException('Text2Image generation using provider "' . $this->getName() . '" failed: Couldn\'t open file to write.');
  303. }
  304. }
  305. if ($this->provider instanceof \OCP\TextToImage\IProviderWithUserId) {
  306. $this->provider->setUserId($userId);
  307. }
  308. try {
  309. $this->provider->generate($input['input'], $resources);
  310. } catch (\RuntimeException $e) {
  311. throw new ProcessingException($e->getMessage(), 0, $e);
  312. }
  313. for ($i = 0; $i < $input['numberOfImages']; $i++) {
  314. if (is_resource($resources[$i])) {
  315. // If $resource hasn't been closed yet, we'll do that here
  316. fclose($resources[$i]);
  317. }
  318. }
  319. return ['images' => array_map(fn (ISimpleFile $file) => $file->getContent(), $files)];
  320. }
  321. public function getInputShapeEnumValues(): array {
  322. return [];
  323. }
  324. public function getInputShapeDefaults(): array {
  325. return [];
  326. }
  327. public function getOptionalInputShapeEnumValues(): array {
  328. return [];
  329. }
  330. public function getOptionalInputShapeDefaults(): array {
  331. return [];
  332. }
  333. public function getOutputShapeEnumValues(): array {
  334. return [];
  335. }
  336. public function getOptionalOutputShapeEnumValues(): array {
  337. return [];
  338. }
  339. };
  340. $newProviders[$newProvider->getId()] = $newProvider;
  341. }
  342. return $newProviders;
  343. }
  344. /**
  345. * This is almost a copy of SpeechToTextManager->getProviders
  346. * to avoid a dependency cycle between SpeechToTextManager and TaskProcessingManager
  347. */
  348. private function _getRawSpeechToTextProviders(): array {
  349. $context = $this->coordinator->getRegistrationContext();
  350. if ($context === null) {
  351. return [];
  352. }
  353. $providers = [];
  354. foreach ($context->getSpeechToTextProviders() as $providerServiceRegistration) {
  355. $class = $providerServiceRegistration->getService();
  356. try {
  357. $providers[$class] = $this->serverContainer->get($class);
  358. } catch (NotFoundExceptionInterface|ContainerExceptionInterface|\Throwable $e) {
  359. $this->logger->error('Failed to load SpeechToText provider ' . $class, [
  360. 'exception' => $e,
  361. ]);
  362. }
  363. }
  364. return $providers;
  365. }
  366. /**
  367. * @return IProvider[]
  368. */
  369. private function _getSpeechToTextProviders(): array {
  370. $oldProviders = $this->_getRawSpeechToTextProviders();
  371. $newProviders = [];
  372. foreach ($oldProviders as $oldProvider) {
  373. $newProvider = new class($oldProvider, $this->rootFolder, $this->appData) implements IProvider, ISynchronousProvider {
  374. private ISpeechToTextProvider $provider;
  375. private IAppData $appData;
  376. private IRootFolder $rootFolder;
  377. public function __construct(ISpeechToTextProvider $provider, IRootFolder $rootFolder, IAppData $appData) {
  378. $this->provider = $provider;
  379. $this->rootFolder = $rootFolder;
  380. $this->appData = $appData;
  381. }
  382. public function getId(): string {
  383. if ($this->provider instanceof ISpeechToTextProviderWithId) {
  384. return Manager::LEGACY_PREFIX_SPEECHTOTEXT . $this->provider->getId();
  385. }
  386. return Manager::LEGACY_PREFIX_SPEECHTOTEXT . $this->provider::class;
  387. }
  388. public function getName(): string {
  389. return $this->provider->getName();
  390. }
  391. public function getTaskTypeId(): string {
  392. return AudioToText::ID;
  393. }
  394. public function getExpectedRuntime(): int {
  395. return 60;
  396. }
  397. public function getOptionalInputShape(): array {
  398. return [];
  399. }
  400. public function getOptionalOutputShape(): array {
  401. return [];
  402. }
  403. public function process(?string $userId, array $input, callable $reportProgress): array {
  404. if ($this->provider instanceof \OCP\SpeechToText\ISpeechToTextProviderWithUserId) {
  405. $this->provider->setUserId($userId);
  406. }
  407. try {
  408. $result = $this->provider->transcribeFile($input['input']);
  409. } catch (\RuntimeException $e) {
  410. throw new ProcessingException($e->getMessage(), 0, $e);
  411. }
  412. return ['output' => $result];
  413. }
  414. public function getInputShapeEnumValues(): array {
  415. return [];
  416. }
  417. public function getInputShapeDefaults(): array {
  418. return [];
  419. }
  420. public function getOptionalInputShapeEnumValues(): array {
  421. return [];
  422. }
  423. public function getOptionalInputShapeDefaults(): array {
  424. return [];
  425. }
  426. public function getOutputShapeEnumValues(): array {
  427. return [];
  428. }
  429. public function getOptionalOutputShapeEnumValues(): array {
  430. return [];
  431. }
  432. };
  433. $newProviders[$newProvider->getId()] = $newProvider;
  434. }
  435. return $newProviders;
  436. }
  437. /**
  438. * Dispatches the event to collect external providers and task types.
  439. * Caches the result within the request.
  440. */
  441. private function dispatchGetProvidersEvent(): GetTaskProcessingProvidersEvent {
  442. if ($this->eventResult !== null) {
  443. return $this->eventResult;
  444. }
  445. $this->eventResult = new GetTaskProcessingProvidersEvent();
  446. $this->dispatcher->dispatchTyped($this->eventResult);
  447. return $this->eventResult ;
  448. }
  449. /**
  450. * @return IProvider[]
  451. */
  452. private function _getProviders(): array {
  453. $context = $this->coordinator->getRegistrationContext();
  454. if ($context === null) {
  455. return [];
  456. }
  457. $providers = [];
  458. foreach ($context->getTaskProcessingProviders() as $providerServiceRegistration) {
  459. $class = $providerServiceRegistration->getService();
  460. try {
  461. /** @var IProvider $provider */
  462. $provider = $this->serverContainer->get($class);
  463. if (isset($providers[$provider->getId()])) {
  464. $this->logger->warning('Task processing provider ' . $class . ' is using ID ' . $provider->getId() . ' which is already used by ' . $providers[$provider->getId()]::class);
  465. }
  466. $providers[$provider->getId()] = $provider;
  467. } catch (\Throwable $e) {
  468. $this->logger->error('Failed to load task processing provider ' . $class, [
  469. 'exception' => $e,
  470. ]);
  471. }
  472. }
  473. $event = $this->dispatchGetProvidersEvent();
  474. $externalProviders = $event->getProviders();
  475. foreach ($externalProviders as $provider) {
  476. if (!isset($providers[$provider->getId()])) {
  477. $providers[$provider->getId()] = $provider;
  478. } else {
  479. $this->logger->info('Skipping external task processing provider with ID ' . $provider->getId() . ' because a local provider with the same ID already exists.');
  480. }
  481. }
  482. $providers += $this->_getTextProcessingProviders() + $this->_getTextToImageProviders() + $this->_getSpeechToTextProviders();
  483. return $providers;
  484. }
  485. /**
  486. * @return ITaskType[]
  487. */
  488. private function _getTaskTypes(): array {
  489. $context = $this->coordinator->getRegistrationContext();
  490. if ($context === null) {
  491. return [];
  492. }
  493. if ($this->taskTypes !== null) {
  494. return $this->taskTypes;
  495. }
  496. // Default task types
  497. $taskTypes = [
  498. \OCP\TaskProcessing\TaskTypes\TextToText::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToText::class),
  499. \OCP\TaskProcessing\TaskTypes\TextToTextTopics::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextTopics::class),
  500. \OCP\TaskProcessing\TaskTypes\TextToTextHeadline::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextHeadline::class),
  501. \OCP\TaskProcessing\TaskTypes\TextToTextSummary::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextSummary::class),
  502. \OCP\TaskProcessing\TaskTypes\TextToTextFormalization::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextFormalization::class),
  503. \OCP\TaskProcessing\TaskTypes\TextToTextSimplification::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextSimplification::class),
  504. \OCP\TaskProcessing\TaskTypes\TextToTextChat::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextChat::class),
  505. \OCP\TaskProcessing\TaskTypes\TextToTextTranslate::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextTranslate::class),
  506. \OCP\TaskProcessing\TaskTypes\TextToTextReformulation::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextReformulation::class),
  507. \OCP\TaskProcessing\TaskTypes\TextToImage::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToImage::class),
  508. \OCP\TaskProcessing\TaskTypes\AudioToText::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\AudioToText::class),
  509. \OCP\TaskProcessing\TaskTypes\ContextWrite::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\ContextWrite::class),
  510. \OCP\TaskProcessing\TaskTypes\GenerateEmoji::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\GenerateEmoji::class),
  511. \OCP\TaskProcessing\TaskTypes\TextToTextChangeTone::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextChangeTone::class),
  512. \OCP\TaskProcessing\TaskTypes\TextToTextChatWithTools::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextChatWithTools::class),
  513. \OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::class),
  514. \OCP\TaskProcessing\TaskTypes\TextToTextProofread::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToTextProofread::class),
  515. \OCP\TaskProcessing\TaskTypes\TextToSpeech::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\TextToSpeech::class),
  516. \OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::class),
  517. \OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::class),
  518. \OCP\TaskProcessing\TaskTypes\AnalyzeImages::ID => \OCP\Server::get(\OCP\TaskProcessing\TaskTypes\AnalyzeImages::class),
  519. ];
  520. foreach ($context->getTaskProcessingTaskTypes() as $providerServiceRegistration) {
  521. $class = $providerServiceRegistration->getService();
  522. try {
  523. /** @var ITaskType $provider */
  524. $taskType = $this->serverContainer->get($class);
  525. if (isset($taskTypes[$taskType->getId()])) {
  526. $this->logger->warning('Task processing task type ' . $class . ' is using ID ' . $taskType->getId() . ' which is already used by ' . $taskTypes[$taskType->getId()]::class);
  527. }
  528. $taskTypes[$taskType->getId()] = $taskType;
  529. } catch (\Throwable $e) {
  530. $this->logger->error('Failed to load task processing task type ' . $class, [
  531. 'exception' => $e,
  532. ]);
  533. }
  534. }
  535. $event = $this->dispatchGetProvidersEvent();
  536. $externalTaskTypes = $event->getTaskTypes();
  537. foreach ($externalTaskTypes as $taskType) {
  538. if (isset($taskTypes[$taskType->getId()])) {
  539. $this->logger->warning('External task processing task type is using ID ' . $taskType->getId() . ' which is already used by a locally registered task type (' . get_class($taskTypes[$taskType->getId()]) . ')');
  540. }
  541. $taskTypes[$taskType->getId()] = $taskType;
  542. }
  543. $taskTypes += $this->_getTextProcessingTaskTypes();
  544. $this->taskTypes = $taskTypes;
  545. return $this->taskTypes;
  546. }
  547. /**
  548. * @return array
  549. */
  550. private function _getTaskTypeSettings(): array {
  551. try {
  552. $json = $this->appConfig->getValueString('core', 'ai.taskprocessing_type_preferences', '', lazy: true);
  553. if ($json === '') {
  554. return [];
  555. }
  556. return json_decode($json, true, flags: JSON_THROW_ON_ERROR);
  557. } catch (\JsonException $e) {
  558. $this->logger->error('Failed to get settings. JSON Error in ai.taskprocessing_type_preferences', ['exception' => $e]);
  559. $taskTypeSettings = [];
  560. $taskTypes = $this->_getTaskTypes();
  561. foreach ($taskTypes as $taskType) {
  562. $taskTypeSettings[$taskType->getId()] = false;
  563. };
  564. return $taskTypeSettings;
  565. }
  566. }
  567. /**
  568. * @param ShapeDescriptor[] $spec
  569. * @param array<array-key, string|numeric> $defaults
  570. * @param array<array-key, ShapeEnumValue[]> $enumValues
  571. * @param array $io
  572. * @param bool $optional
  573. * @return void
  574. * @throws ValidationException
  575. */
  576. private static function validateInput(array $spec, array $defaults, array $enumValues, array $io, bool $optional = false): void {
  577. foreach ($spec as $key => $descriptor) {
  578. $type = $descriptor->getShapeType();
  579. if (!isset($io[$key])) {
  580. if ($optional) {
  581. continue;
  582. }
  583. if (isset($defaults[$key])) {
  584. if (EShapeType::getScalarType($type) !== $type) {
  585. throw new ValidationException('Provider tried to set a default value for a non-scalar slot');
  586. }
  587. if (EShapeType::isFileType($type)) {
  588. throw new ValidationException('Provider tried to set a default value for a slot that is not text or number');
  589. }
  590. $type->validateInput($defaults[$key]);
  591. continue;
  592. }
  593. throw new ValidationException('Missing key: "' . $key . '"');
  594. }
  595. try {
  596. $type->validateInput($io[$key]);
  597. if ($type === EShapeType::Enum) {
  598. if (!isset($enumValues[$key])) {
  599. throw new ValidationException('Provider did not provide enum values for an enum slot: "' . $key . '"');
  600. }
  601. $type->validateEnum($io[$key], $enumValues[$key]);
  602. }
  603. } catch (ValidationException $e) {
  604. throw new ValidationException('Failed to validate input key "' . $key . '": ' . $e->getMessage());
  605. }
  606. }
  607. }
  608. /**
  609. * Takes task input data and replaces fileIds with File objects
  610. *
  611. * @param array<array-key, list<numeric|string>|numeric|string> $input
  612. * @param array<array-key, numeric|string> ...$defaultSpecs the specs
  613. * @return array<array-key, list<numeric|string>|numeric|string>
  614. */
  615. public function fillInputDefaults(array $input, ...$defaultSpecs): array {
  616. $spec = array_reduce($defaultSpecs, fn ($carry, $spec) => array_merge($carry, $spec), []);
  617. return array_merge($spec, $input);
  618. }
  619. /**
  620. * @param ShapeDescriptor[] $spec
  621. * @param array<array-key, ShapeEnumValue[]> $enumValues
  622. * @param array $io
  623. * @param bool $optional
  624. * @return void
  625. * @throws ValidationException
  626. */
  627. private static function validateOutputWithFileIds(array $spec, array $enumValues, array $io, bool $optional = false): void {
  628. foreach ($spec as $key => $descriptor) {
  629. $type = $descriptor->getShapeType();
  630. if (!isset($io[$key])) {
  631. if ($optional) {
  632. continue;
  633. }
  634. throw new ValidationException('Missing key: "' . $key . '"');
  635. }
  636. try {
  637. $type->validateOutputWithFileIds($io[$key]);
  638. if (isset($enumValues[$key])) {
  639. $type->validateEnum($io[$key], $enumValues[$key]);
  640. }
  641. } catch (ValidationException $e) {
  642. throw new ValidationException('Failed to validate output key "' . $key . '": ' . $e->getMessage());
  643. }
  644. }
  645. }
  646. /**
  647. * @param ShapeDescriptor[] $spec
  648. * @param array<array-key, ShapeEnumValue[]> $enumValues
  649. * @param array $io
  650. * @param bool $optional
  651. * @return void
  652. * @throws ValidationException
  653. */
  654. private static function validateOutputWithFileData(array $spec, array $enumValues, array $io, bool $optional = false): void {
  655. foreach ($spec as $key => $descriptor) {
  656. $type = $descriptor->getShapeType();
  657. if (!isset($io[$key])) {
  658. if ($optional) {
  659. continue;
  660. }
  661. throw new ValidationException('Missing key: "' . $key . '"');
  662. }
  663. try {
  664. $type->validateOutputWithFileData($io[$key]);
  665. if (isset($enumValues[$key])) {
  666. $type->validateEnum($io[$key], $enumValues[$key]);
  667. }
  668. } catch (ValidationException $e) {
  669. throw new ValidationException('Failed to validate output key "' . $key . '": ' . $e->getMessage());
  670. }
  671. }
  672. }
  673. /**
  674. * @param array<array-key, T> $array The array to filter
  675. * @param ShapeDescriptor[] ...$specs the specs that define which keys to keep
  676. * @return array<array-key, T>
  677. * @psalm-template T
  678. */
  679. private function removeSuperfluousArrayKeys(array $array, ...$specs): array {
  680. $keys = array_unique(array_reduce($specs, fn ($carry, $spec) => array_merge($carry, array_keys($spec)), []));
  681. $keys = array_filter($keys, fn ($key) => array_key_exists($key, $array));
  682. $values = array_map(fn (string $key) => $array[$key], $keys);
  683. return array_combine($keys, $values);
  684. }
  685. public function hasProviders(): bool {
  686. return count($this->getProviders()) !== 0;
  687. }
  688. public function getProviders(): array {
  689. if ($this->providers === null) {
  690. $this->providers = $this->_getProviders();
  691. }
  692. return $this->providers;
  693. }
  694. public function getPreferredProvider(string $taskTypeId) {
  695. try {
  696. if ($this->preferences === null) {
  697. $this->preferences = $this->distributedCache->get('ai.taskprocessing_provider_preferences');
  698. if ($this->preferences === null) {
  699. $this->preferences = json_decode(
  700. $this->appConfig->getValueString('core', 'ai.taskprocessing_provider_preferences', 'null', lazy: true),
  701. associative: true,
  702. flags: JSON_THROW_ON_ERROR,
  703. );
  704. $this->distributedCache->set('ai.taskprocessing_provider_preferences', $this->preferences, 60 * 3);
  705. }
  706. }
  707. $providers = $this->getProviders();
  708. if (isset($this->preferences[$taskTypeId])) {
  709. $providersById = $this->providersById ?? array_reduce($providers, static function (array $carry, IProvider $provider) {
  710. $carry[$provider->getId()] = $provider;
  711. return $carry;
  712. }, []);
  713. $this->providersById = $providersById;
  714. if (isset($providersById[$this->preferences[$taskTypeId]])) {
  715. return $providersById[$this->preferences[$taskTypeId]];
  716. }
  717. }
  718. // By default, use the first available provider
  719. foreach ($providers as $provider) {
  720. if ($provider->getTaskTypeId() === $taskTypeId) {
  721. return $provider;
  722. }
  723. }
  724. } catch (\JsonException $e) {
  725. $this->logger->warning('Failed to parse provider preferences while getting preferred provider for task type ' . $taskTypeId, ['exception' => $e]);
  726. }
  727. throw new \OCP\TaskProcessing\Exception\Exception('No matching provider found');
  728. }
  729. public function getAvailableTaskTypes(bool $showDisabled = false, ?string $userId = null): array {
  730. // We cache by language, because some task type fields are translated
  731. $cacheKey = self::TASK_TYPES_CACHE_KEY . ':' . $this->l10nFactory->findLanguage();
  732. // userId will be obtained from the session if left to null
  733. if (!$this->checkGuestAccess($userId)) {
  734. return [];
  735. }
  736. if ($this->availableTaskTypes === null) {
  737. $cachedValue = $this->distributedCache->get($cacheKey);
  738. if ($cachedValue !== null) {
  739. $this->availableTaskTypes = unserialize($cachedValue);
  740. }
  741. }
  742. // Either we have no cache or showDisabled is turned on, which we don't want to cache, ever.
  743. if ($this->availableTaskTypes === null || $showDisabled) {
  744. $taskTypes = $this->_getTaskTypes();
  745. $taskTypeSettings = $this->_getTaskTypeSettings();
  746. $availableTaskTypes = [];
  747. foreach ($taskTypes as $taskType) {
  748. if ((!$showDisabled) && isset($taskTypeSettings[$taskType->getId()]) && !$taskTypeSettings[$taskType->getId()]) {
  749. continue;
  750. }
  751. try {
  752. $provider = $this->getPreferredProvider($taskType->getId());
  753. } catch (\OCP\TaskProcessing\Exception\Exception $e) {
  754. continue;
  755. }
  756. try {
  757. $availableTaskTypes[$provider->getTaskTypeId()] = [
  758. 'name' => $taskType->getName(),
  759. 'description' => $taskType->getDescription(),
  760. 'optionalInputShape' => $provider->getOptionalInputShape(),
  761. 'inputShapeEnumValues' => $provider->getInputShapeEnumValues(),
  762. 'inputShapeDefaults' => $provider->getInputShapeDefaults(),
  763. 'inputShape' => $taskType->getInputShape(),
  764. 'optionalInputShapeEnumValues' => $provider->getOptionalInputShapeEnumValues(),
  765. 'optionalInputShapeDefaults' => $provider->getOptionalInputShapeDefaults(),
  766. 'outputShape' => $taskType->getOutputShape(),
  767. 'outputShapeEnumValues' => $provider->getOutputShapeEnumValues(),
  768. 'optionalOutputShape' => $provider->getOptionalOutputShape(),
  769. 'optionalOutputShapeEnumValues' => $provider->getOptionalOutputShapeEnumValues(),
  770. 'isInternal' => $taskType instanceof IInternalTaskType,
  771. ];
  772. } catch (\Throwable $e) {
  773. $this->logger->error('Failed to set up TaskProcessing provider ' . $provider::class, ['exception' => $e]);
  774. }
  775. }
  776. if ($showDisabled) {
  777. // Do not cache showDisabled, ever.
  778. return $availableTaskTypes;
  779. }
  780. $this->availableTaskTypes = $availableTaskTypes;
  781. $this->distributedCache->set($cacheKey, serialize($this->availableTaskTypes), 60);
  782. }
  783. return $this->availableTaskTypes;
  784. }
  785. public function getAvailableTaskTypeIds(bool $showDisabled = false, ?string $userId = null): array {
  786. // userId will be obtained from the session if left to null
  787. if (!$this->checkGuestAccess($userId)) {
  788. return [];
  789. }
  790. if ($this->availableTaskTypeIds === null) {
  791. $cachedValue = $this->distributedCache->get(self::TASK_TYPE_IDS_CACHE_KEY);
  792. if ($cachedValue !== null) {
  793. $this->availableTaskTypeIds = $cachedValue;
  794. }
  795. }
  796. // Either we have no cache or showDisabled is turned on, which we don't want to cache, ever.
  797. if ($this->availableTaskTypeIds === null || $showDisabled) {
  798. $taskTypes = $this->_getTaskTypes();
  799. $taskTypeSettings = $this->_getTaskTypeSettings();
  800. $availableTaskTypeIds = [];
  801. foreach ($taskTypes as $taskType) {
  802. if ((!$showDisabled) && isset($taskTypeSettings[$taskType->getId()]) && !$taskTypeSettings[$taskType->getId()]) {
  803. continue;
  804. }
  805. try {
  806. $provider = $this->getPreferredProvider($taskType->getId());
  807. } catch (\OCP\TaskProcessing\Exception\Exception $e) {
  808. continue;
  809. }
  810. $availableTaskTypeIds[] = $taskType->getId();
  811. }
  812. if ($showDisabled) {
  813. // Do not cache showDisabled, ever.
  814. return $availableTaskTypeIds;
  815. }
  816. $this->availableTaskTypeIds = $availableTaskTypeIds;
  817. $this->distributedCache->set(self::TASK_TYPE_IDS_CACHE_KEY, $this->availableTaskTypeIds, 60);
  818. }
  819. return $this->availableTaskTypeIds;
  820. }
  821. public function canHandleTask(Task $task): bool {
  822. return isset($this->getAvailableTaskTypes()[$task->getTaskTypeId()]);
  823. }
  824. private function checkGuestAccess(?string $userId = null): bool {
  825. if ($userId === null && !$this->userSession->isLoggedIn()) {
  826. return true;
  827. }
  828. if ($userId === null) {
  829. $user = $this->userSession->getUser();
  830. } else {
  831. $user = $this->userManager->get($userId);
  832. }
  833. $guestsAllowed = $this->appConfig->getValueString('core', 'ai.taskprocessing_guests', 'false');
  834. if ($guestsAllowed == 'true' || !class_exists(\OCA\Guests\UserBackend::class) || !($user->getBackend() instanceof \OCA\Guests\UserBackend)) {
  835. return true;
  836. }
  837. return false;
  838. }
  839. public function scheduleTask(Task $task): void {
  840. if (!$this->checkGuestAccess($task->getUserId())) {
  841. throw new \OCP\TaskProcessing\Exception\PreConditionNotMetException('Access to this resource is forbidden for guests.');
  842. }
  843. if (!$this->canHandleTask($task)) {
  844. throw new \OCP\TaskProcessing\Exception\PreConditionNotMetException('No task processing provider is installed that can handle this task type: ' . $task->getTaskTypeId());
  845. }
  846. $this->prepareTask($task);
  847. $task->setStatus(Task::STATUS_SCHEDULED);
  848. $this->storeTask($task);
  849. // schedule synchronous job if the provider is synchronous
  850. $provider = $this->getPreferredProvider($task->getTaskTypeId());
  851. if ($provider instanceof ISynchronousProvider) {
  852. $this->jobList->add(SynchronousBackgroundJob::class, null);
  853. }
  854. if ($provider instanceof ITriggerableProvider) {
  855. try {
  856. if (!$this->taskMapper->hasRunningTasksForTaskType($task->getTaskTypeId())) {
  857. // If no tasks are currently running for this task type, nudge the provider to ask for tasks
  858. $provider->trigger();
  859. }
  860. } catch (Exception $e) {
  861. $this->logger->error('Failed to check DB for running tasks after a task was scheduled for a triggerable provider. Not triggering the provider.', ['exception' => $e]);
  862. }
  863. }
  864. }
  865. public function runTask(Task $task): Task {
  866. if (!$this->checkGuestAccess($task->getUserId())) {
  867. throw new \OCP\TaskProcessing\Exception\PreConditionNotMetException('Access to this resource is forbidden for guests.');
  868. }
  869. if (!$this->canHandleTask($task)) {
  870. throw new \OCP\TaskProcessing\Exception\PreConditionNotMetException('No task processing provider is installed that can handle this task type: ' . $task->getTaskTypeId());
  871. }
  872. $provider = $this->getPreferredProvider($task->getTaskTypeId());
  873. if ($provider instanceof ISynchronousProvider) {
  874. $this->prepareTask($task);
  875. $task->setStatus(Task::STATUS_SCHEDULED);
  876. $this->storeTask($task);
  877. $this->processTask($task, $provider);
  878. $task = $this->getTask($task->getId());
  879. } else {
  880. $this->scheduleTask($task);
  881. // poll task
  882. while ($task->getStatus() === Task::STATUS_SCHEDULED || $task->getStatus() === Task::STATUS_RUNNING) {
  883. sleep(1);
  884. $task = $this->getTask($task->getId());
  885. }
  886. }
  887. return $task;
  888. }
  889. public function processTask(Task $task, ISynchronousProvider $provider): bool {
  890. try {
  891. try {
  892. $input = $this->prepareInputData($task);
  893. } catch (GenericFileException|NotPermittedException|LockedException|ValidationException|UnauthorizedException $e) {
  894. $this->logger->warning('Failed to prepare input data for a TaskProcessing task with synchronous provider ' . $provider->getId(), ['exception' => $e]);
  895. $this->setTaskResult($task->getId(), $e->getMessage(), null);
  896. return false;
  897. }
  898. try {
  899. $this->setTaskStatus($task, Task::STATUS_RUNNING);
  900. $output = $provider->process($task->getUserId(), $input, fn (float $progress) => $this->setTaskProgress($task->getId(), $progress));
  901. } catch (ProcessingException $e) {
  902. $this->logger->warning('Failed to process a TaskProcessing task with synchronous provider ' . $provider->getId(), ['exception' => $e]);
  903. $this->setTaskResult($task->getId(), $e->getMessage(), null);
  904. return false;
  905. } catch (\Throwable $e) {
  906. $this->logger->error('Unknown error while processing TaskProcessing task', ['exception' => $e]);
  907. $this->setTaskResult($task->getId(), $e->getMessage(), null);
  908. return false;
  909. }
  910. $this->setTaskResult($task->getId(), null, $output);
  911. } catch (NotFoundException $e) {
  912. $this->logger->info('Could not find task anymore after execution. Moving on.', ['exception' => $e]);
  913. } catch (Exception $e) {
  914. $this->logger->error('Failed to report result of TaskProcessing task', ['exception' => $e]);
  915. }
  916. return true;
  917. }
  918. public function deleteTask(Task $task): void {
  919. $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
  920. $this->taskMapper->delete($taskEntity);
  921. }
  922. public function getTask(int $id): Task {
  923. try {
  924. $taskEntity = $this->taskMapper->find($id);
  925. return $taskEntity->toPublicTask();
  926. } catch (DoesNotExistException $e) {
  927. throw new NotFoundException('Couldn\'t find task with id ' . $id, 0, $e);
  928. } catch (MultipleObjectsReturnedException|\OCP\DB\Exception $e) {
  929. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e);
  930. } catch (\JsonException $e) {
  931. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', 0, $e);
  932. }
  933. }
  934. public function cancelTask(int $id): void {
  935. $task = $this->getTask($id);
  936. if ($task->getStatus() !== Task::STATUS_SCHEDULED && $task->getStatus() !== Task::STATUS_RUNNING) {
  937. return;
  938. }
  939. $task->setStatus(Task::STATUS_CANCELLED);
  940. $task->setEndedAt(time());
  941. $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
  942. try {
  943. $this->taskMapper->update($taskEntity);
  944. $this->runWebhook($task);
  945. } catch (\OCP\DB\Exception $e) {
  946. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e);
  947. }
  948. }
  949. public function setTaskProgress(int $id, float $progress): bool {
  950. // TODO: Not sure if we should rather catch the exceptions of getTask here and fail silently
  951. $task = $this->getTask($id);
  952. if ($task->getStatus() === Task::STATUS_CANCELLED) {
  953. return false;
  954. }
  955. // only set the start time if the task is going from scheduled to running
  956. if ($task->getstatus() === Task::STATUS_SCHEDULED) {
  957. $task->setStartedAt(time());
  958. }
  959. $task->setStatus(Task::STATUS_RUNNING);
  960. $task->setProgress($progress);
  961. $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
  962. try {
  963. $this->taskMapper->update($taskEntity);
  964. } catch (\OCP\DB\Exception $e) {
  965. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e);
  966. }
  967. return true;
  968. }
  969. public function setTaskResult(int $id, ?string $error, ?array $result, bool $isUsingFileIds = false): void {
  970. // TODO: Not sure if we should rather catch the exceptions of getTask here and fail silently
  971. $task = $this->getTask($id);
  972. if ($task->getStatus() === Task::STATUS_CANCELLED) {
  973. $this->logger->info('A TaskProcessing ' . $task->getTaskTypeId() . ' task with id ' . $id . ' finished but was cancelled in the mean time. Moving on without storing result.');
  974. return;
  975. }
  976. if ($error !== null) {
  977. $task->setStatus(Task::STATUS_FAILED);
  978. $task->setEndedAt(time());
  979. // truncate error message to 1000 characters
  980. $task->setErrorMessage(mb_substr($error, 0, 1000));
  981. $this->logger->warning('A TaskProcessing ' . $task->getTaskTypeId() . ' task with id ' . $id . ' failed with the following message: ' . $error);
  982. } elseif ($result !== null) {
  983. $taskTypes = $this->getAvailableTaskTypes();
  984. $outputShape = $taskTypes[$task->getTaskTypeId()]['outputShape'];
  985. $outputShapeEnumValues = $taskTypes[$task->getTaskTypeId()]['outputShapeEnumValues'];
  986. $optionalOutputShape = $taskTypes[$task->getTaskTypeId()]['optionalOutputShape'];
  987. $optionalOutputShapeEnumValues = $taskTypes[$task->getTaskTypeId()]['optionalOutputShapeEnumValues'];
  988. try {
  989. // validate output
  990. if (!$isUsingFileIds) {
  991. $this->validateOutputWithFileData($outputShape, $outputShapeEnumValues, $result);
  992. $this->validateOutputWithFileData($optionalOutputShape, $optionalOutputShapeEnumValues, $result, true);
  993. } else {
  994. $this->validateOutputWithFileIds($outputShape, $outputShapeEnumValues, $result);
  995. $this->validateOutputWithFileIds($optionalOutputShape, $optionalOutputShapeEnumValues, $result, true);
  996. }
  997. $output = $this->removeSuperfluousArrayKeys($result, $outputShape, $optionalOutputShape);
  998. // extract raw data and put it in files, replace it with file ids
  999. if (!$isUsingFileIds) {
  1000. $output = $this->encapsulateOutputFileData($output, $outputShape, $optionalOutputShape);
  1001. } else {
  1002. $this->validateOutputFileIds($output, $outputShape, $optionalOutputShape);
  1003. }
  1004. // Turn file objects into IDs
  1005. foreach ($output as $key => $value) {
  1006. if ($value instanceof Node) {
  1007. $output[$key] = $value->getId();
  1008. }
  1009. if (is_array($value) && isset($value[0]) && $value[0] instanceof Node) {
  1010. $output[$key] = array_map(fn ($node) => $node->getId(), $value);
  1011. }
  1012. }
  1013. $task->setOutput($output);
  1014. $task->setProgress(1);
  1015. $task->setStatus(Task::STATUS_SUCCESSFUL);
  1016. $task->setEndedAt(time());
  1017. } catch (ValidationException $e) {
  1018. $task->setProgress(1);
  1019. $task->setStatus(Task::STATUS_FAILED);
  1020. $task->setEndedAt(time());
  1021. $error = 'The task was processed successfully but the provider\'s output doesn\'t pass validation against the task type\'s outputShape spec and/or the provider\'s own optionalOutputShape spec';
  1022. $task->setErrorMessage($error);
  1023. $this->logger->error($error, ['exception' => $e, 'output' => $result]);
  1024. } catch (NotPermittedException $e) {
  1025. $task->setProgress(1);
  1026. $task->setStatus(Task::STATUS_FAILED);
  1027. $task->setEndedAt(time());
  1028. $error = 'The task was processed successfully but storing the output in a file failed';
  1029. $task->setErrorMessage($error);
  1030. $this->logger->error($error, ['exception' => $e]);
  1031. } catch (InvalidPathException|\OCP\Files\NotFoundException $e) {
  1032. $task->setProgress(1);
  1033. $task->setStatus(Task::STATUS_FAILED);
  1034. $task->setEndedAt(time());
  1035. $error = 'The task was processed successfully but the result file could not be found';
  1036. $task->setErrorMessage($error);
  1037. $this->logger->error($error, ['exception' => $e]);
  1038. }
  1039. }
  1040. try {
  1041. $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
  1042. } catch (\JsonException $e) {
  1043. throw new \OCP\TaskProcessing\Exception\Exception('The task was processed successfully but the provider\'s output could not be encoded as JSON for the database.', 0, $e);
  1044. }
  1045. try {
  1046. $this->taskMapper->update($taskEntity);
  1047. $this->runWebhook($task);
  1048. } catch (\OCP\DB\Exception $e) {
  1049. throw new \OCP\TaskProcessing\Exception\Exception($e->getMessage());
  1050. }
  1051. if ($task->getStatus() === Task::STATUS_SUCCESSFUL) {
  1052. $event = new TaskSuccessfulEvent($task);
  1053. } else {
  1054. $event = new TaskFailedEvent($task, $error);
  1055. }
  1056. $this->dispatcher->dispatchTyped($event);
  1057. }
  1058. public function getNextScheduledTask(array $taskTypeIds = [], array $taskIdsToIgnore = []): Task {
  1059. try {
  1060. $taskEntity = $this->taskMapper->findOldestScheduledByType($taskTypeIds, $taskIdsToIgnore);
  1061. return $taskEntity->toPublicTask();
  1062. } catch (DoesNotExistException $e) {
  1063. throw new \OCP\TaskProcessing\Exception\NotFoundException('Could not find the task', previous: $e);
  1064. } catch (\OCP\DB\Exception $e) {
  1065. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', previous: $e);
  1066. } catch (\JsonException $e) {
  1067. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', previous: $e);
  1068. }
  1069. }
  1070. public function getNextScheduledTasks(array $taskTypeIds = [], array $taskIdsToIgnore = [], int $numberOfTasks = 1): array {
  1071. try {
  1072. return array_map(fn ($taskEntity) => $taskEntity->toPublicTask(), $this->taskMapper->findNOldestScheduledByType($taskTypeIds, $taskIdsToIgnore, $numberOfTasks));
  1073. } catch (DoesNotExistException $e) {
  1074. throw new \OCP\TaskProcessing\Exception\NotFoundException('Could not find the task', previous: $e);
  1075. } catch (\OCP\DB\Exception $e) {
  1076. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', previous: $e);
  1077. } catch (\JsonException $e) {
  1078. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', previous: $e);
  1079. }
  1080. }
  1081. /**
  1082. * Takes task input data and replaces fileIds with File objects
  1083. *
  1084. * @param string|null $userId
  1085. * @param array<array-key, list<numeric|string>|numeric|string> $input
  1086. * @param ShapeDescriptor[] ...$specs the specs
  1087. * @return array<array-key, list<File|numeric|string>|numeric|string|File>
  1088. * @throws GenericFileException|LockedException|NotPermittedException|ValidationException|UnauthorizedException
  1089. */
  1090. public function fillInputFileData(?string $userId, array $input, ...$specs): array {
  1091. if ($userId !== null) {
  1092. \OC_Util::setupFS($userId);
  1093. }
  1094. $newInputOutput = [];
  1095. $spec = array_reduce($specs, fn ($carry, $spec) => $carry + $spec, []);
  1096. foreach ($spec as $key => $descriptor) {
  1097. $type = $descriptor->getShapeType();
  1098. if (!isset($input[$key])) {
  1099. continue;
  1100. }
  1101. if (!in_array(EShapeType::getScalarType($type), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) {
  1102. $newInputOutput[$key] = $input[$key];
  1103. continue;
  1104. }
  1105. if (EShapeType::getScalarType($type) === $type) {
  1106. // is scalar
  1107. $node = $this->validateFileId((int)$input[$key]);
  1108. $this->validateUserAccessToFile($input[$key], $userId);
  1109. $newInputOutput[$key] = $node;
  1110. } else {
  1111. // is list
  1112. $newInputOutput[$key] = [];
  1113. foreach ($input[$key] as $item) {
  1114. $node = $this->validateFileId((int)$item);
  1115. $this->validateUserAccessToFile($item, $userId);
  1116. $newInputOutput[$key][] = $node;
  1117. }
  1118. }
  1119. }
  1120. return $newInputOutput;
  1121. }
  1122. public function getUserTask(int $id, ?string $userId): Task {
  1123. try {
  1124. $taskEntity = $this->taskMapper->findByIdAndUser($id, $userId);
  1125. return $taskEntity->toPublicTask();
  1126. } catch (DoesNotExistException $e) {
  1127. throw new \OCP\TaskProcessing\Exception\NotFoundException('Could not find the task', 0, $e);
  1128. } catch (MultipleObjectsReturnedException|\OCP\DB\Exception $e) {
  1129. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the task', 0, $e);
  1130. } catch (\JsonException $e) {
  1131. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the task', 0, $e);
  1132. }
  1133. }
  1134. public function getUserTasks(?string $userId, ?string $taskTypeId = null, ?string $customId = null): array {
  1135. try {
  1136. $taskEntities = $this->taskMapper->findByUserAndTaskType($userId, $taskTypeId, $customId);
  1137. return array_map(fn ($taskEntity): Task => $taskEntity->toPublicTask(), $taskEntities);
  1138. } catch (\OCP\DB\Exception $e) {
  1139. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the tasks', 0, $e);
  1140. } catch (\JsonException $e) {
  1141. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the tasks', 0, $e);
  1142. }
  1143. }
  1144. public function getTasks(
  1145. ?string $userId, ?string $taskTypeId = null, ?string $appId = null, ?string $customId = null,
  1146. ?int $status = null, ?int $scheduleAfter = null, ?int $endedBefore = null,
  1147. ): array {
  1148. try {
  1149. $taskEntities = $this->taskMapper->findTasks($userId, $taskTypeId, $appId, $customId, $status, $scheduleAfter, $endedBefore);
  1150. return array_map(fn ($taskEntity): Task => $taskEntity->toPublicTask(), $taskEntities);
  1151. } catch (\OCP\DB\Exception $e) {
  1152. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding the tasks', 0, $e);
  1153. } catch (\JsonException $e) {
  1154. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding the tasks', 0, $e);
  1155. }
  1156. }
  1157. public function getUserTasksByApp(?string $userId, string $appId, ?string $customId = null): array {
  1158. try {
  1159. $taskEntities = $this->taskMapper->findUserTasksByApp($userId, $appId, $customId);
  1160. return array_map(fn ($taskEntity): Task => $taskEntity->toPublicTask(), $taskEntities);
  1161. } catch (\OCP\DB\Exception $e) {
  1162. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem finding a task', 0, $e);
  1163. } catch (\JsonException $e) {
  1164. throw new \OCP\TaskProcessing\Exception\Exception('There was a problem parsing JSON after finding a task', 0, $e);
  1165. }
  1166. }
  1167. /**
  1168. *Takes task input or output and replaces base64 data with file ids
  1169. *
  1170. * @param array $output
  1171. * @param ShapeDescriptor[] ...$specs the specs that define which keys to keep
  1172. * @return array
  1173. * @throws NotPermittedException
  1174. */
  1175. public function encapsulateOutputFileData(array $output, ...$specs): array {
  1176. $newOutput = [];
  1177. try {
  1178. $folder = $this->appData->getFolder('TaskProcessing');
  1179. } catch (\OCP\Files\NotFoundException) {
  1180. $folder = $this->appData->newFolder('TaskProcessing');
  1181. }
  1182. $spec = array_reduce($specs, fn ($carry, $spec) => $carry + $spec, []);
  1183. foreach ($spec as $key => $descriptor) {
  1184. $type = $descriptor->getShapeType();
  1185. if (!isset($output[$key])) {
  1186. continue;
  1187. }
  1188. if (!in_array(EShapeType::getScalarType($type), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) {
  1189. $newOutput[$key] = $output[$key];
  1190. continue;
  1191. }
  1192. if (EShapeType::getScalarType($type) === $type) {
  1193. /** @var SimpleFile $file */
  1194. $file = $folder->newFile(time() . '-' . rand(1, 100000), $output[$key]);
  1195. $newOutput[$key] = $file->getId(); // polymorphic call to SimpleFile
  1196. } else {
  1197. $newOutput = [];
  1198. foreach ($output[$key] as $item) {
  1199. /** @var SimpleFile $file */
  1200. $file = $folder->newFile(time() . '-' . rand(1, 100000), $item);
  1201. $newOutput[$key][] = $file->getId();
  1202. }
  1203. }
  1204. }
  1205. return $newOutput;
  1206. }
  1207. /**
  1208. * @param Task $task
  1209. * @return array<array-key, list<numeric|string|File>|numeric|string|File>
  1210. * @throws GenericFileException
  1211. * @throws LockedException
  1212. * @throws NotPermittedException
  1213. * @throws ValidationException|UnauthorizedException
  1214. */
  1215. public function prepareInputData(Task $task): array {
  1216. $taskTypes = $this->getAvailableTaskTypes();
  1217. $inputShape = $taskTypes[$task->getTaskTypeId()]['inputShape'];
  1218. $optionalInputShape = $taskTypes[$task->getTaskTypeId()]['optionalInputShape'];
  1219. $input = $task->getInput();
  1220. $input = $this->removeSuperfluousArrayKeys($input, $inputShape, $optionalInputShape);
  1221. $input = $this->fillInputFileData($task->getUserId(), $input, $inputShape, $optionalInputShape);
  1222. return $input;
  1223. }
  1224. public function lockTask(Task $task): bool {
  1225. $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
  1226. if ($this->taskMapper->lockTask($taskEntity) === 0) {
  1227. return false;
  1228. }
  1229. $task->setStatus(Task::STATUS_RUNNING);
  1230. return true;
  1231. }
  1232. /**
  1233. * @throws \JsonException
  1234. * @throws Exception
  1235. */
  1236. public function setTaskStatus(Task $task, int $status): void {
  1237. $currentTaskStatus = $task->getStatus();
  1238. if ($currentTaskStatus === Task::STATUS_SCHEDULED && $status === Task::STATUS_RUNNING) {
  1239. $task->setStartedAt(time());
  1240. } elseif ($currentTaskStatus === Task::STATUS_RUNNING && ($status === Task::STATUS_FAILED || $status === Task::STATUS_CANCELLED)) {
  1241. $task->setEndedAt(time());
  1242. } elseif ($currentTaskStatus === Task::STATUS_UNKNOWN && $status === Task::STATUS_SCHEDULED) {
  1243. $task->setScheduledAt(time());
  1244. }
  1245. $task->setStatus($status);
  1246. $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
  1247. $this->taskMapper->update($taskEntity);
  1248. }
  1249. /**
  1250. * Validate input, fill input default values, set completionExpectedAt, set scheduledAt
  1251. *
  1252. * @param Task $task
  1253. * @return void
  1254. * @throws UnauthorizedException
  1255. * @throws ValidationException
  1256. * @throws \OCP\TaskProcessing\Exception\Exception
  1257. */
  1258. private function prepareTask(Task $task): void {
  1259. $taskTypes = $this->getAvailableTaskTypes();
  1260. $taskType = $taskTypes[$task->getTaskTypeId()];
  1261. $inputShape = $taskType['inputShape'];
  1262. $inputShapeDefaults = $taskType['inputShapeDefaults'];
  1263. $inputShapeEnumValues = $taskType['inputShapeEnumValues'];
  1264. $optionalInputShape = $taskType['optionalInputShape'];
  1265. $optionalInputShapeEnumValues = $taskType['optionalInputShapeEnumValues'];
  1266. $optionalInputShapeDefaults = $taskType['optionalInputShapeDefaults'];
  1267. // validate input
  1268. $this->validateInput($inputShape, $inputShapeDefaults, $inputShapeEnumValues, $task->getInput());
  1269. $this->validateInput($optionalInputShape, $optionalInputShapeDefaults, $optionalInputShapeEnumValues, $task->getInput(), true);
  1270. // authenticate access to mentioned files
  1271. $ids = [];
  1272. foreach ($inputShape + $optionalInputShape as $key => $descriptor) {
  1273. if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) {
  1274. /** @var list<int>|int $inputSlot */
  1275. $inputSlot = $task->getInput()[$key];
  1276. if (is_array($inputSlot)) {
  1277. $ids += $inputSlot;
  1278. } else {
  1279. $ids[] = $inputSlot;
  1280. }
  1281. }
  1282. }
  1283. foreach ($ids as $fileId) {
  1284. $this->validateFileId($fileId);
  1285. $this->validateUserAccessToFile($fileId, $task->getUserId());
  1286. }
  1287. // remove superfluous keys and set input
  1288. $input = $this->removeSuperfluousArrayKeys($task->getInput(), $inputShape, $optionalInputShape);
  1289. $inputWithDefaults = $this->fillInputDefaults($input, $inputShapeDefaults, $optionalInputShapeDefaults);
  1290. $task->setInput($inputWithDefaults);
  1291. $task->setScheduledAt(time());
  1292. $provider = $this->getPreferredProvider($task->getTaskTypeId());
  1293. // calculate expected completion time
  1294. $completionExpectedAt = new \DateTime('now');
  1295. $completionExpectedAt->add(new \DateInterval('PT' . $provider->getExpectedRuntime() . 'S'));
  1296. $task->setCompletionExpectedAt($completionExpectedAt);
  1297. }
  1298. /**
  1299. * Store the task in the DB and set its ID in the \OCP\TaskProcessing\Task input param
  1300. *
  1301. * @param Task $task
  1302. * @return void
  1303. * @throws Exception
  1304. * @throws \JsonException
  1305. */
  1306. private function storeTask(Task $task): void {
  1307. // create a db entity and insert into db table
  1308. $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task);
  1309. $this->taskMapper->insert($taskEntity);
  1310. // make sure the scheduler knows the id
  1311. $task->setId($taskEntity->getId());
  1312. }
  1313. /**
  1314. * @param array $output
  1315. * @param ShapeDescriptor[] ...$specs the specs that define which keys to keep
  1316. * @return array
  1317. * @throws NotPermittedException
  1318. */
  1319. private function validateOutputFileIds(array $output, ...$specs): array {
  1320. $newOutput = [];
  1321. $spec = array_reduce($specs, fn ($carry, $spec) => $carry + $spec, []);
  1322. foreach ($spec as $key => $descriptor) {
  1323. $type = $descriptor->getShapeType();
  1324. if (!isset($output[$key])) {
  1325. continue;
  1326. }
  1327. if (!in_array(EShapeType::getScalarType($type), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) {
  1328. $newOutput[$key] = $output[$key];
  1329. continue;
  1330. }
  1331. if (EShapeType::getScalarType($type) === $type) {
  1332. // Is scalar file ID
  1333. $newOutput[$key] = $this->validateFileId($output[$key]);
  1334. } else {
  1335. // Is list of file IDs
  1336. $newOutput = [];
  1337. foreach ($output[$key] as $item) {
  1338. $newOutput[$key][] = $this->validateFileId($item);
  1339. }
  1340. }
  1341. }
  1342. return $newOutput;
  1343. }
  1344. /**
  1345. * @param mixed $id
  1346. * @return File
  1347. * @throws ValidationException
  1348. */
  1349. private function validateFileId(mixed $id): File {
  1350. $node = $this->rootFolder->getFirstNodeById($id);
  1351. if ($node === null) {
  1352. $node = $this->rootFolder->getFirstNodeByIdInPath($id, '/' . $this->rootFolder->getAppDataDirectoryName() . '/');
  1353. if ($node === null) {
  1354. throw new ValidationException('Could not find file ' . $id);
  1355. } elseif (!$node instanceof File) {
  1356. throw new ValidationException('File with id "' . $id . '" is not a file');
  1357. }
  1358. } elseif (!$node instanceof File) {
  1359. throw new ValidationException('File with id "' . $id . '" is not a file');
  1360. }
  1361. return $node;
  1362. }
  1363. /**
  1364. * @param mixed $fileId
  1365. * @param string|null $userId
  1366. * @return void
  1367. * @throws UnauthorizedException
  1368. */
  1369. private function validateUserAccessToFile(mixed $fileId, ?string $userId): void {
  1370. if ($userId === null) {
  1371. throw new UnauthorizedException('User does not have access to file ' . $fileId);
  1372. }
  1373. $mounts = $this->userMountCache->getMountsForFileId($fileId);
  1374. $userIds = array_map(fn ($mount) => $mount->getUser()->getUID(), $mounts);
  1375. if (!in_array($userId, $userIds)) {
  1376. throw new UnauthorizedException('User ' . $userId . ' does not have access to file ' . $fileId);
  1377. }
  1378. }
  1379. /**
  1380. * @param Task $task
  1381. * @return list<int>
  1382. * @throws NotFoundException
  1383. */
  1384. public function extractFileIdsFromTask(Task $task): array {
  1385. $ids = [];
  1386. $taskTypes = $this->getAvailableTaskTypes();
  1387. if (!isset($taskTypes[$task->getTaskTypeId()])) {
  1388. throw new NotFoundException('Could not find task type');
  1389. }
  1390. $taskType = $taskTypes[$task->getTaskTypeId()];
  1391. foreach ($taskType['inputShape'] + $taskType['optionalInputShape'] as $key => $descriptor) {
  1392. if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) {
  1393. /** @var int|list<int> $inputSlot */
  1394. $inputSlot = $task->getInput()[$key];
  1395. if (is_array($inputSlot)) {
  1396. $ids = array_merge($inputSlot, $ids);
  1397. } else {
  1398. $ids[] = $inputSlot;
  1399. }
  1400. }
  1401. }
  1402. if ($task->getOutput() !== null) {
  1403. foreach ($taskType['outputShape'] + $taskType['optionalOutputShape'] as $key => $descriptor) {
  1404. if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) {
  1405. /** @var int|list<int> $outputSlot */
  1406. $outputSlot = $task->getOutput()[$key];
  1407. if (is_array($outputSlot)) {
  1408. $ids = array_merge($outputSlot, $ids);
  1409. } else {
  1410. $ids[] = $outputSlot;
  1411. }
  1412. }
  1413. }
  1414. }
  1415. return $ids;
  1416. }
  1417. /**
  1418. * @param ISimpleFolder $folder
  1419. * @param int $ageInSeconds
  1420. * @return \Generator
  1421. */
  1422. public function clearFilesOlderThan(ISimpleFolder $folder, int $ageInSeconds = self::MAX_TASK_AGE_SECONDS): \Generator {
  1423. foreach ($folder->getDirectoryListing() as $file) {
  1424. if ($file->getMTime() < time() - $ageInSeconds) {
  1425. try {
  1426. $fileName = $file->getName();
  1427. $file->delete();
  1428. yield $fileName;
  1429. } catch (NotPermittedException $e) {
  1430. $this->logger->warning('Failed to delete a stale task processing file', ['exception' => $e]);
  1431. }
  1432. }
  1433. }
  1434. }
  1435. /**
  1436. * @param int $ageInSeconds
  1437. * @return \Generator
  1438. * @throws Exception
  1439. * @throws InvalidPathException
  1440. * @throws NotFoundException
  1441. * @throws \JsonException
  1442. * @throws \OCP\Files\NotFoundException
  1443. */
  1444. public function cleanupTaskProcessingTaskFiles(int $ageInSeconds = self::MAX_TASK_AGE_SECONDS): \Generator {
  1445. $taskIdsToCleanup = [];
  1446. foreach ($this->taskMapper->getTasksToCleanup($ageInSeconds) as $task) {
  1447. $taskIdsToCleanup[] = $task->getId();
  1448. $ocpTask = $task->toPublicTask();
  1449. $fileIds = $this->extractFileIdsFromTask($ocpTask);
  1450. foreach ($fileIds as $fileId) {
  1451. // only look for output files stored in appData/TaskProcessing/
  1452. $file = $this->rootFolder->getFirstNodeByIdInPath($fileId, '/' . $this->rootFolder->getAppDataDirectoryName() . '/core/TaskProcessing/');
  1453. if ($file instanceof File) {
  1454. try {
  1455. $fileId = $file->getId();
  1456. $fileName = $file->getName();
  1457. $file->delete();
  1458. yield ['task_id' => $task->getId(), 'file_id' => $fileId, 'file_name' => $fileName];
  1459. } catch (NotPermittedException $e) {
  1460. $this->logger->warning('Failed to delete a stale task processing file', ['exception' => $e]);
  1461. }
  1462. }
  1463. }
  1464. }
  1465. return $taskIdsToCleanup;
  1466. }
  1467. /**
  1468. * Make a request to the task's webhookUri if necessary
  1469. *
  1470. * @param Task $task
  1471. */
  1472. private function runWebhook(Task $task): void {
  1473. $uri = $task->getWebhookUri();
  1474. $method = $task->getWebhookMethod();
  1475. if (!$uri || !$method) {
  1476. return;
  1477. }
  1478. if (in_array($method, ['HTTP:GET', 'HTTP:POST', 'HTTP:PUT', 'HTTP:DELETE'], true)) {
  1479. $client = $this->clientService->newClient();
  1480. $httpMethod = preg_replace('/^HTTP:/', '', $method);
  1481. $options = [
  1482. 'timeout' => 30,
  1483. 'body' => json_encode([
  1484. 'task' => $task->jsonSerialize(),
  1485. ]),
  1486. 'headers' => ['Content-Type' => 'application/json'],
  1487. ];
  1488. try {
  1489. $client->request($httpMethod, $uri, $options);
  1490. } catch (ClientException|ServerException $e) {
  1491. $this->logger->warning('Task processing HTTP webhook failed for task ' . $task->getId() . '. Request failed', ['exception' => $e]);
  1492. } catch (\Exception|\Throwable $e) {
  1493. $this->logger->warning('Task processing HTTP webhook failed for task ' . $task->getId() . '. Unknown error', ['exception' => $e]);
  1494. }
  1495. } elseif (str_starts_with($method, 'AppAPI:') && str_starts_with($uri, '/')) {
  1496. $parsedMethod = explode(':', $method, 4);
  1497. if (count($parsedMethod) < 3) {
  1498. $this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. Invalid method: ' . $method);
  1499. }
  1500. [, $exAppId, $httpMethod] = $parsedMethod;
  1501. if (!$this->appManager->isEnabledForAnyone('app_api')) {
  1502. $this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. AppAPI is disabled or not installed.');
  1503. return;
  1504. }
  1505. try {
  1506. $appApiFunctions = \OCP\Server::get(\OCA\AppAPI\PublicFunctions::class);
  1507. } catch (ContainerExceptionInterface|NotFoundExceptionInterface) {
  1508. $this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. Could not get AppAPI public functions.');
  1509. return;
  1510. }
  1511. $exApp = $appApiFunctions->getExApp($exAppId);
  1512. if ($exApp === null) {
  1513. $this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. ExApp ' . $exAppId . ' is missing.');
  1514. return;
  1515. } elseif (!$exApp['enabled']) {
  1516. $this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. ExApp ' . $exAppId . ' is disabled.');
  1517. return;
  1518. }
  1519. $requestParams = [
  1520. 'task' => $task->jsonSerialize(),
  1521. ];
  1522. $requestOptions = [
  1523. 'timeout' => 30,
  1524. ];
  1525. $response = $appApiFunctions->exAppRequest($exAppId, $uri, $task->getUserId(), $httpMethod, $requestParams, $requestOptions);
  1526. if (is_array($response) && isset($response['error'])) {
  1527. $this->logger->warning('Task processing AppAPI webhook failed for task ' . $task->getId() . '. Error during request to ExApp(' . $exAppId . '): ', $response['error']);
  1528. }
  1529. }
  1530. }
  1531. }