From 1c8f31a86ce75902ae90c02ac8d04e4d1fc23d07 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Tue, 29 Oct 2024 15:19:32 +0100 Subject: [PATCH 1/5] feat(chat): Add API to summarize chat messages Signed-off-by: Joas Schilling --- appinfo/routes/routesChatController.php | 2 + docs/capabilities.md | 1 + lib/Capabilities.php | 15 ++ lib/Controller/ChatController.php | 145 ++++++++++++++++++++ lib/Exceptions/ChatSummaryException.php | 30 ++++ tests/php/CapabilitiesTest.php | 109 +++++++-------- tests/php/Controller/ChatControllerTest.php | 16 +++ 7 files changed, 261 insertions(+), 57 deletions(-) create mode 100644 lib/Exceptions/ChatSummaryException.php diff --git a/appinfo/routes/routesChatController.php b/appinfo/routes/routesChatController.php index d6933d1ca6d..c9998a8a7cc 100644 --- a/appinfo/routes/routesChatController.php +++ b/appinfo/routes/routesChatController.php @@ -21,6 +21,8 @@ 'ocs' => [ /** @see \OCA\Talk\Controller\ChatController::receiveMessages() */ ['name' => 'Chat#receiveMessages', 'url' => '/api/{apiVersion}/chat/{token}', 'verb' => 'GET', 'requirements' => $requirements], + /** @see \OCA\Talk\Controller\ChatController::summarizeChat() */ + ['name' => 'Chat#summarizeChat', 'url' => '/api/{apiVersion}/chat/{token}/summarize', 'verb' => 'POST', 'requirements' => $requirements], /** @see \OCA\Talk\Controller\ChatController::sendMessage() */ ['name' => 'Chat#sendMessage', 'url' => '/api/{apiVersion}/chat/{token}', 'verb' => 'POST', 'requirements' => $requirements], /** @see \OCA\Talk\Controller\ChatController::clearHistory() */ diff --git a/docs/capabilities.md b/docs/capabilities.md index f8bebf371cd..e9590648870 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -160,5 +160,6 @@ * `archived-conversations` (local) - Conversations can be marked as archived which will hide them from the conversation list by default * `talk-polls-drafts` - Whether moderators can store and retrieve poll drafts * `download-call-participants` - Whether the endpoints for moderators to download the call participants is available +* `chat-summary-api` (local) - Whether the endpoint to get summarized chat messages in a conversation is available * `config => call => start-without-media` (local) - Boolean, whether media should be disabled when starting or joining a conversation * `config => call => max-duration` - Integer, maximum call duration in seconds. Please note that this should only be used with system cron and with a reasonable high value, due to the expended duration until the background job ran. diff --git a/lib/Capabilities.php b/lib/Capabilities.php index cb30c4bb443..8f71b405494 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -18,6 +18,8 @@ use OCP\IConfig; use OCP\IUser; use OCP\IUserSession; +use OCP\TaskProcessing\IManager as ITaskProcessingManager; +use OCP\TaskProcessing\TaskTypes\TextToTextSummary; use OCP\Translation\ITranslationManager; use OCP\Util; @@ -108,6 +110,12 @@ class Capabilities implements IPublicCapability { 'download-call-participants', ]; + public const CONDITIONAL_FEATURES = [ + 'message-expiration', + 'reactions', + 'chat-summary-api', + ]; + public const LOCAL_FEATURES = [ 'favorites', 'chat-read-status', @@ -119,6 +127,7 @@ class Capabilities implements IPublicCapability { 'remind-me-later', 'note-to-self', 'archived-conversations', + 'chat-summary-api', ]; public const LOCAL_CONFIGS = [ @@ -164,6 +173,7 @@ public function __construct( protected IUserSession $userSession, protected IAppManager $appManager, protected ITranslationManager $translationManager, + protected ITaskProcessingManager $taskProcessingManager, ICacheFactory $cacheFactory, ) { $this->talkCache = $cacheFactory->createLocal('talk::'); @@ -300,6 +310,11 @@ public function getCapabilities(): array { $capabilities['config']['call']['can-enable-sip'] = $this->talkConfig->canUserEnableSIP($user); } + $supportedTaskTypes = $this->taskProcessingManager->getAvailableTaskTypes(); + if (isset($supportedTaskTypes[TextToTextSummary::ID])) { + $capabilities['features'][] = 'chat-summary-api'; + } + return [ 'spreed' => $capabilities, ]; diff --git a/lib/Controller/ChatController.php b/lib/Controller/ChatController.php index e598b0e9e26..3c0c10b3cd5 100644 --- a/lib/Controller/ChatController.php +++ b/lib/Controller/ChatController.php @@ -8,6 +8,7 @@ namespace OCA\Talk\Controller; +use OCA\Talk\AppInfo\Application; use OCA\Talk\Chat\AutoComplete\SearchPlugin; use OCA\Talk\Chat\AutoComplete\Sorter; use OCA\Talk\Chat\ChatManager; @@ -15,6 +16,7 @@ use OCA\Talk\Chat\Notifier; use OCA\Talk\Chat\ReactionManager; use OCA\Talk\Exceptions\CannotReachRemoteException; +use OCA\Talk\Exceptions\ChatSummaryException; use OCA\Talk\Federation\Authenticator; use OCA\Talk\GuestManager; use OCA\Talk\MatterbridgeManager; @@ -51,6 +53,7 @@ use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Services\IAppConfig; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Collaboration\AutoComplete\IManager; use OCP\Collaboration\Collaborators\ISearchResult; @@ -62,14 +65,20 @@ use OCP\IRequest; use OCP\IUserManager; use OCP\RichObjectStrings\InvalidObjectExeption; +use OCP\RichObjectStrings\IRichTextFormatter; use OCP\RichObjectStrings\IValidator; use OCP\Security\ITrustedDomainHelper; use OCP\Security\RateLimiting\IRateLimitExceededException; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IShare; +use OCP\TaskProcessing\Exception\Exception; +use OCP\TaskProcessing\IManager as ITaskProcessingManager; +use OCP\TaskProcessing\Task; +use OCP\TaskProcessing\TaskTypes\TextToTextSummary; use OCP\User\Events\UserLiveStatusEvent; use OCP\UserStatus\IManager as IUserStatusManager; use OCP\UserStatus\IUserStatus; +use Psr\Log\LoggerInterface; /** * @psalm-import-type TalkChatMentionSuggestion from ResponseDefinitions @@ -114,6 +123,10 @@ public function __construct( protected Authenticator $federationAuthenticator, protected ProxyCacheMessageService $pcmService, protected Notifier $notifier, + protected IRichTextFormatter $richTextFormatter, + protected ITaskProcessingManager $taskProcessingManager, + protected IAppConfig $appConfig, + protected LoggerInterface $logger, ) { parent::__construct($appName, $request); } @@ -489,6 +502,138 @@ public function receiveMessages(int $lookIntoFuture, return $this->prepareCommentsAsDataResponse($comments, $lastCommonReadId); } + /** + * Summarize the next bunch of chat messages from a given offset + * + * Required capability: `chat-summary-api` + * + * @param positive-int $fromMessageId Offset from where on the summary should be generated + * @return DataResponse|DataResponse|DataResponse, array{}> + * @throws \InvalidArgumentException + * + * 201: Summary was scheduled, use the returned taskId to get the status + * information and output from the TaskProcessing API: + * https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-taskprocessing-api.html#fetch-a-task-by-id + * If the response data contains nextOffset, not all messages could be handled in a single request. + * After receiving the response a second summary should be requested with the provided nextOffset. + * 204: No messages found to summarize + * 400: No AI provider available or summarizing failed + */ + #[PublicPage] + #[RequireModeratorOrNoLobby] + #[RequireParticipant] + public function summarizeChat( + int $fromMessageId, + ): DataResponse { + $fromMessageId = max(0, $fromMessageId); + + $supportedTaskTypes = $this->taskProcessingManager->getAvailableTaskTypes(); + if (!isset($supportedTaskTypes[TextToTextSummary::ID])) { + return new DataResponse([ + 'error' => ChatSummaryException::REASON_AI_ERROR, + ], Http::STATUS_BAD_REQUEST); + } + + // if ($this->room->isFederatedConversation()) { + // /** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController $proxy */ + // $proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ChatController::class); + // return $proxy->summarizeChat( + // $this->room, + // $this->participant, + // $fromMessageId, + // ); + // } + + $currentUser = $this->userManager->get($this->userId); + $batchSize = $this->appConfig->getAppValueInt('ai_unread_summary_batch_size', 500); + $comments = $this->chatManager->waitForNewMessages($this->room, $fromMessageId, $batchSize, 0, $currentUser, true, false); + $this->preloadShares($comments); + + $messages = []; + $nextOffset = 0; + foreach ($comments as $comment) { + $message = $this->messageParser->createMessage($this->room, $this->participant, $comment, $this->l); + $this->messageParser->parseMessage($message); + + if (!$message->getVisibility()) { + continue; + } + + if ($message->getMessageType() === ChatManager::VERB_SYSTEM + && !in_array($message->getMessageRaw(), [ + 'call_ended', + 'call_ended_everyone', + 'file_shared', + 'object_shared', + ], true)) { + // Ignore system messages apart from calls, shared objects and files + continue; + } + + $parsedMessage = $this->richTextFormatter->richToParsed( + $message->getMessage(), + $message->getMessageParameters(), + ); + + $displayName = $message->getActorDisplayName(); + if (in_array($message->getActorType(), [ + Attendee::ACTOR_GUESTS, + Attendee::ACTOR_EMAILS, + ], true)) { + if ($displayName === '') { + $displayName = $this->l->t('Guest'); + } else { + $displayName = $this->l->t('%s (guest)', $displayName); + } + } + + if ($comment->getParentId() !== '0') { + // FIXME should add something? + } + + $messages[] = $displayName . ': ' . $parsedMessage; + $nextOffset = (int)$comment->getId(); + } + + if (empty($messages)) { + return new DataResponse([], Http::STATUS_NO_CONTENT); + } + + $task = new Task( + TextToTextSummary::ID, + ['input' => implode("\n\n", $messages)], + Application::APP_ID, + $this->userId, + 'summary/' . $this->room->getToken(), + ); + + try { + $this->taskProcessingManager->scheduleTask($task); + } catch (Exception $e) { + $this->logger->error('An error occurred while trying to summarize unread messages', ['exception' => $e]); + return new DataResponse([ + 'error' => ChatSummaryException::REASON_AI_ERROR, + ], Http::STATUS_BAD_REQUEST); + } + + $taskId = $task->getId(); + if ($taskId === null) { + return new DataResponse([ + 'error' => ChatSummaryException::REASON_AI_ERROR, + ], Http::STATUS_BAD_REQUEST); + } + + $data = [ + 'taskId' => $taskId, + ]; + + if ($nextOffset !== $this->room->getLastMessageId()) { + $data['nextOffset'] = $nextOffset; + } + + return new DataResponse($data, Http::STATUS_CREATED); + } + /** * @return DataResponse */ diff --git a/lib/Exceptions/ChatSummaryException.php b/lib/Exceptions/ChatSummaryException.php new file mode 100644 index 00000000000..170e304b984 --- /dev/null +++ b/lib/Exceptions/ChatSummaryException.php @@ -0,0 +1,30 @@ +reason; + } +} diff --git a/tests/php/CapabilitiesTest.php b/tests/php/CapabilitiesTest.php index f18ab3d3c55..4a8e758cc0e 100644 --- a/tests/php/CapabilitiesTest.php +++ b/tests/php/CapabilitiesTest.php @@ -21,6 +21,9 @@ use OCP\IConfig; use OCP\IUser; use OCP\IUserSession; +use OCP\TaskProcessing\IManager as ITaskProcessingManager; +use OCP\TaskProcessing\TaskTypes\TextToTextFormalization; +use OCP\TaskProcessing\TaskTypes\TextToTextSummary; use OCP\Translation\ITranslationManager; use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; @@ -33,6 +36,7 @@ class CapabilitiesTest extends TestCase { protected IUserSession&MockObject $userSession; protected IAppManager&MockObject $appManager; protected ITranslationManager&MockObject $translationManager; + protected ITaskProcessingManager&MockObject $taskProcessingManager; protected ICacheFactory&MockObject $cacheFactory; protected ICache&MockObject $talkCache; @@ -45,6 +49,7 @@ public function setUp(): void { $this->userSession = $this->createMock(IUserSession::class); $this->appManager = $this->createMock(IAppManager::class); $this->translationManager = $this->createMock(ITranslationManager::class); + $this->taskProcessingManager = $this->createMock(ITaskProcessingManager::class); $this->cacheFactory = $this->createMock(ICacheFactory::class); $this->talkCache = $this->createMock(ICache::class); @@ -62,8 +67,8 @@ public function setUp(): void { ->willReturn('1.2.3'); } - public function testGetCapabilitiesGuest(): void { - $capabilities = new Capabilities( + protected function getCapabilities(): Capabilities { + return new Capabilities( $this->serverConfig, $this->talkConfig, $this->appConfig, @@ -71,8 +76,13 @@ public function testGetCapabilitiesGuest(): void { $this->userSession, $this->appManager, $this->translationManager, + $this->taskProcessingManager, $this->cacheFactory, ); + } + + public function testGetCapabilitiesGuest(): void { + $capabilities = $this->getCapabilities(); $this->userSession->expects($this->once()) ->method('getUser') @@ -172,16 +182,7 @@ public static function dataGetCapabilitiesUserAllowed(): array { * @dataProvider dataGetCapabilitiesUserAllowed */ public function testGetCapabilitiesUserAllowed(bool $isNotAllowed, bool $canCreate, string $quota, bool $canUpload, int $readPrivacy): void { - $capabilities = new Capabilities( - $this->serverConfig, - $this->talkConfig, - $this->appConfig, - $this->commentsManager, - $this->userSession, - $this->appManager, - $this->translationManager, - $this->cacheFactory, - ); + $capabilities = $this->getCapabilities(); $user = $this->createMock(IUser::class); $user->expects($this->atLeastOnce()) @@ -218,6 +219,9 @@ public function testGetCapabilitiesUserAllowed(bool $isNotAllowed, bool $canCrea $user->method('getQuota') ->willReturn($quota); + $this->taskProcessingManager->method('getAvailableTaskTypes') + ->willReturn([TextToTextSummary::ID => true]); + $this->serverConfig->expects($this->any()) ->method('getAppValue') ->willReturnMap([ @@ -236,6 +240,7 @@ public function testGetCapabilitiesUserAllowed(bool $isNotAllowed, bool $canCrea Capabilities::FEATURES, [ 'message-expiration', 'reactions', + 'chat-summary-api', ] ), 'features-local' => Capabilities::LOCAL_FEATURES, @@ -293,19 +298,35 @@ public function testGetCapabilitiesUserAllowed(bool $isNotAllowed, bool $canCrea 'version' => '1.2.3', ], ], $data); + } + + public function testCapabilitiesDocumentation(): void { + foreach (Capabilities::FEATURES as $feature) { + $suffix = ''; + if (in_array($feature, Capabilities::LOCAL_FEATURES)) { + $suffix = ' (local)'; + } + $this->assertCapabilityIsDocumented("`$feature`" . $suffix); + } - foreach ($data['spreed']['features'] as $feature) { + foreach (Capabilities::CONDITIONAL_FEATURES as $feature) { $suffix = ''; - if (in_array($feature, $data['spreed']['features-local'])) { + if (in_array($feature, Capabilities::LOCAL_FEATURES)) { $suffix = ' (local)'; } $this->assertCapabilityIsDocumented("`$feature`" . $suffix); } - foreach ($data['spreed']['config'] as $feature => $configs) { - foreach ($configs as $config => $configData) { + $openapi = json_decode(file_get_contents(__DIR__ . '/../../openapi.json'), true, flags: JSON_THROW_ON_ERROR); + $configDefinition = $openapi['components']['schemas']['Capabilities']['properties']['config']['properties'] ?? null; + $this->assertIsArray($configDefinition, 'Failed to read Capabilities config from openapi.json'); + + $configFeatures = array_keys($configDefinition); + + foreach ($configFeatures as $feature) { + foreach (array_keys($configDefinition[$feature]['properties']) as $config) { $suffix = ''; - if (isset($data['spreed']['config-local'][$feature]) && in_array($config, $data['spreed']['config-local'][$feature])) { + if (isset($data['spreed']['config-local'][$feature]) && in_array($config, Capabilities::LOCAL_CONFIGS[$feature])) { $suffix = ' (local)'; } $this->assertCapabilityIsDocumented("`config => $feature => $config`" . $suffix); @@ -319,16 +340,7 @@ protected function assertCapabilityIsDocumented(string $capability): void { } public function testGetCapabilitiesUserDisallowed(): void { - $capabilities = new Capabilities( - $this->serverConfig, - $this->talkConfig, - $this->appConfig, - $this->commentsManager, - $this->userSession, - $this->appManager, - $this->translationManager, - $this->cacheFactory, - ); + $capabilities = $this->getCapabilities(); $user = $this->createMock(IUser::class); $this->userSession->expects($this->once()) @@ -345,16 +357,7 @@ public function testGetCapabilitiesUserDisallowed(): void { } public function testCapabilitiesHelloV2Key(): void { - $capabilities = new Capabilities( - $this->serverConfig, - $this->talkConfig, - $this->appConfig, - $this->commentsManager, - $this->userSession, - $this->appManager, - $this->translationManager, - $this->cacheFactory, - ); + $capabilities = $this->getCapabilities(); $this->talkConfig->expects($this->once()) ->method('getSignalingTokenPublicKey') @@ -375,16 +378,7 @@ public static function dataTestConfigRecording(): array { * @dataProvider dataTestConfigRecording */ public function testConfigRecording(bool $enabled): void { - $capabilities = new Capabilities( - $this->serverConfig, - $this->talkConfig, - $this->appConfig, - $this->commentsManager, - $this->userSession, - $this->appManager, - $this->translationManager, - $this->cacheFactory, - ); + $capabilities = $this->getCapabilities(); $this->talkConfig->expects($this->once()) ->method('isRecordingEnabled') @@ -395,16 +389,7 @@ public function testConfigRecording(bool $enabled): void { } public function testCapabilitiesTranslations(): void { - $capabilities = new Capabilities( - $this->serverConfig, - $this->talkConfig, - $this->appConfig, - $this->commentsManager, - $this->userSession, - $this->appManager, - $this->translationManager, - $this->cacheFactory, - ); + $capabilities = $this->getCapabilities(); $this->translationManager->method('hasProviders') ->willReturn(true); @@ -412,4 +397,14 @@ public function testCapabilitiesTranslations(): void { $data = json_decode(json_encode($capabilities->getCapabilities(), JSON_THROW_ON_ERROR), true); $this->assertEquals(true, $data['spreed']['config']['chat']['has-translation-providers']); } + + public function testSummaryTaskProviders(): void { + $capabilities = $this->getCapabilities(); + + $this->taskProcessingManager->method('getAvailableTaskTypes') + ->willReturn([TextToTextFormalization::ID => true]); + + $data = json_decode(json_encode($capabilities->getCapabilities(), JSON_THROW_ON_ERROR), true); + $this->assertNotContains('chat-summary-api', $data['spreed']['features']); + } } diff --git a/tests/php/Controller/ChatControllerTest.php b/tests/php/Controller/ChatControllerTest.php index 0209b3ffe41..37d6cc41360 100644 --- a/tests/php/Controller/ChatControllerTest.php +++ b/tests/php/Controller/ChatControllerTest.php @@ -34,6 +34,7 @@ use OCP\App\IAppManager; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Services\IAppConfig; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Collaboration\AutoComplete\IManager; use OCP\Collaboration\Collaborators\ISearchResult; @@ -43,12 +44,15 @@ use OCP\IRequest; use OCP\IUser; use OCP\IUserManager; +use OCP\RichObjectStrings\IRichTextFormatter; use OCP\RichObjectStrings\IValidator; use OCP\Security\ITrustedDomainHelper; +use OCP\TaskProcessing\IManager as ITaskProcessingManager; use OCP\UserStatus\IManager as IUserStatusManager; use PHPUnit\Framework\Assert; use PHPUnit\Framework\Constraint\Callback; use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; use Test\TestCase; class ChatControllerTest extends TestCase { @@ -81,6 +85,10 @@ class ChatControllerTest extends TestCase { private Authenticator&MockObject $federationAuthenticator; private ProxyCacheMessageService&MockObject $pcmService; private Notifier&MockObject $notifier; + private IRichTextFormatter&MockObject $richTextFormatter; + private ITaskProcessingManager&MockObject $taskProcessingManager; + private IAppConfig&MockObject $appConfig; + private LoggerInterface&MockObject $logger; protected Room&MockObject $room; @@ -121,6 +129,10 @@ public function setUp(): void { $this->federationAuthenticator = $this->createMock(Authenticator::class); $this->pcmService = $this->createMock(ProxyCacheMessageService::class); $this->notifier = $this->createMock(Notifier::class); + $this->richTextFormatter = $this->createMock(IRichTextFormatter::class); + $this->taskProcessingManager = $this->createMock(ITaskProcessingManager::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->logger = $this->createMock(LoggerInterface::class); $this->room = $this->createMock(Room::class); @@ -167,6 +179,10 @@ private function recreateChatController() { $this->federationAuthenticator, $this->pcmService, $this->notifier, + $this->richTextFormatter, + $this->taskProcessingManager, + $this->appConfig, + $this->logger, ); } From 6a1718d2653012138aa0070e60e6cd3c3c10bbdf Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Tue, 29 Oct 2024 15:21:31 +0100 Subject: [PATCH 2/5] chore(assets): Recompile assets Signed-off-by: Joas Schilling --- openapi-full.json | 173 ++++++++++++++++++++++++++++++ openapi.json | 173 ++++++++++++++++++++++++++++++ src/types/openapi/openapi-full.ts | 98 +++++++++++++++++ src/types/openapi/openapi.ts | 98 +++++++++++++++++ 4 files changed, 542 insertions(+) diff --git a/openapi-full.json b/openapi-full.json index ee2f367ec0e..25e6768594a 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -6086,6 +6086,179 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/summarize": { + "post": { + "operationId": "chat-summarize-chat", + "summary": "Summarize the next bunch of chat messages from a given offset", + "description": "Required capability: `chat-summary-api`", + "tags": [ + "chat" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "fromMessageId" + ], + "properties": { + "fromMessageId": { + "type": "integer", + "format": "int64", + "description": "Offset from where on the summary should be generated", + "minimum": 1 + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "Summary was scheduled, use the returned taskId to get the status information and output from the TaskProcessing API: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-taskprocessing-api.html#fetch-a-task-by-id If the response data contains nextOffset, not all messages could be handled in a single request. After receiving the response a second summary should be requested with the provided nextOffset.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "taskId" + ], + "properties": { + "taskId": { + "type": "integer", + "format": "int64" + }, + "nextOffset": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "No AI provider available or summarizing failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "ai-no-provider", + "ai-error" + ] + } + } + } + } + } + } + } + } + } + }, + "204": { + "description": "No messages found to summarize" + }, + "500": { + "description": "", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/{messageId}": { "delete": { "operationId": "chat-delete-message", diff --git a/openapi.json b/openapi.json index cdfc0dadfab..6fc2796ac7a 100644 --- a/openapi.json +++ b/openapi.json @@ -5973,6 +5973,179 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/summarize": { + "post": { + "operationId": "chat-summarize-chat", + "summary": "Summarize the next bunch of chat messages from a given offset", + "description": "Required capability: `chat-summary-api`", + "tags": [ + "chat" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "fromMessageId" + ], + "properties": { + "fromMessageId": { + "type": "integer", + "format": "int64", + "description": "Offset from where on the summary should be generated", + "minimum": 1 + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "Summary was scheduled, use the returned taskId to get the status information and output from the TaskProcessing API: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-taskprocessing-api.html#fetch-a-task-by-id If the response data contains nextOffset, not all messages could be handled in a single request. After receiving the response a second summary should be requested with the provided nextOffset.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "taskId" + ], + "properties": { + "taskId": { + "type": "integer", + "format": "int64" + }, + "nextOffset": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "No AI provider available or summarizing failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "ai-no-provider", + "ai-error" + ] + } + } + } + } + } + } + } + } + } + }, + "204": { + "description": "No messages found to summarize" + }, + "500": { + "description": "", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/{messageId}": { "delete": { "operationId": "chat-delete-message", diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index d097abeb12c..02d939a1e29 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -361,6 +361,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/summarize": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Summarize the next bunch of chat messages from a given offset + * @description Required capability: `chat-summary-api` + */ + post: operations["chat-summarize-chat"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/{messageId}": { parameters: { query?: never; @@ -4090,6 +4110,84 @@ export interface operations { }; }; }; + "chat-summarize-chat": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * Format: int64 + * @description Offset from where on the summary should be generated + */ + fromMessageId: number; + }; + }; + }; + responses: { + /** @description Summary was scheduled, use the returned taskId to get the status information and output from the TaskProcessing API: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-taskprocessing-api.html#fetch-a-task-by-id If the response data contains nextOffset, not all messages could be handled in a single request. After receiving the response a second summary should be requested with the provided nextOffset. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** Format: int64 */ + taskId: number; + /** Format: int64 */ + nextOffset?: number; + }; + }; + }; + }; + }; + /** @description No messages found to summarize */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No AI provider available or summarizing failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "ai-no-provider" | "ai-error"; + }; + }; + }; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + }; + }; "chat-edit-message": { parameters: { query?: never; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 303985fa07d..ce61b47bc9c 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -361,6 +361,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/summarize": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Summarize the next bunch of chat messages from a given offset + * @description Required capability: `chat-summary-api` + */ + post: operations["chat-summarize-chat"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/{messageId}": { parameters: { query?: never; @@ -3571,6 +3591,84 @@ export interface operations { }; }; }; + "chat-summarize-chat": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * Format: int64 + * @description Offset from where on the summary should be generated + */ + fromMessageId: number; + }; + }; + }; + responses: { + /** @description Summary was scheduled, use the returned taskId to get the status information and output from the TaskProcessing API: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-taskprocessing-api.html#fetch-a-task-by-id If the response data contains nextOffset, not all messages could be handled in a single request. After receiving the response a second summary should be requested with the provided nextOffset. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** Format: int64 */ + taskId: number; + /** Format: int64 */ + nextOffset?: number; + }; + }; + }; + }; + }; + /** @description No messages found to summarize */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No AI provider available or summarizing failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "ai-no-provider" | "ai-error"; + }; + }; + }; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + }; + }; "chat-edit-message": { parameters: { query?: never; From 993ce10ebdc01b1a3d332f87d868d713aefdfa30 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Fri, 8 Nov 2024 16:49:02 +0100 Subject: [PATCH 3/5] test: Add integration test for summary Signed-off-by: Joas Schilling --- .../features/bootstrap/FeatureContext.php | 107 +++++++++++++++++- .../features/chat-4/summary.feature | 23 ++++ 2 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 tests/integration/features/chat-4/summary.feature diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 70df9b2c84e..6810c8261ba 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -40,6 +40,8 @@ class FeatureContext implements Context, SnippetAcceptingContext { /** @var array */ protected static array $messageIdToText; /** @var array */ + protected static array $aiTaskIds; + /** @var array */ protected static array $remoteToInviteId; /** @var array */ protected static array $inviteIdToRemote; @@ -113,6 +115,8 @@ class FeatureContext implements Context, SnippetAcceptingContext { private ?SharingContext $sharingContext; private array $guestsAppWasEnabled = []; + private array $testingAppWasEnabled = []; + private array $taskProcessingProviderPreference = []; private array $guestsOldWhitelist = []; @@ -184,6 +188,7 @@ public function __construct() { foreach (['LOCAL', 'REMOTE'] as $server) { $this->changedConfigs[$server] = []; $this->guestsAppWasEnabled[$server] = null; + $this->testingAppWasEnabled[$server] = null; $this->guestsOldWhitelist[$server] = ''; } } @@ -2422,6 +2427,43 @@ public function userSharesRichObjectToRoom($user, $type, $id, $metaData, $identi } } + /** + * @Then /^user "([^"]*)" requests summary for "([^"]*)" starting from ("[^"]*"|'[^']*') with (\d+)(?: \((v1)\))?$/ + */ + public function userSummarizesRoom(string $user, string $identifier, string $message, string $statusCode, string $apiVersion = 'v1', ?TableNode $tableNode = null): void { + $message = substr($message, 1, -1); + $fromMessageId = self::$textToMessageId[$message]; + + $this->setCurrentUser($user, $identifier); + $this->sendRequest( + 'POST', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/summarize', + ['fromMessageId' => $fromMessageId], + ); + $this->assertStatusCode($this->response, $statusCode); + sleep(1); // make sure Postgres manages the order of the messages + + $response = $this->getDataFromResponse($this->response); + self::$aiTaskIds[$user . '/summary/' . self::$identifierToToken[$identifier]] = $response['taskId']; + if (isset($tableNode?->getRowsHash()['nextOffset'])) { + Assert::assertSame(self::$textToMessageId[$tableNode->getRowsHash()['nextOffset']], $response['nextOffset'], 'Offset ID does not match'); + } elseif (isset($response['nextOffset'])) { + Assert::assertArrayNotHasKey('nextOffset', $response, 'Did not expect a follow-up offset key on response, but received: ' . self::$messageIdToText[$response['nextOffset']]); + } + } + + /** + * @Then /^user "([^"]*)" receives summary for "([^"]*)" with (\d+)$/ + */ + public function userReceivesSummary(string $user, string $identifier, string $statusCode, ?TableNode $tableNode = null): void { + $this->sendRequest( + 'GET', '/taskprocessing/task/' . self::$aiTaskIds[$user . '/summary/' . self::$identifierToToken[$identifier]], + ); + $this->assertStatusCode($this->response, $statusCode); + $response = $this->getDataFromResponse($this->response); + Assert::assertNotNull($response['task']['output'], 'Task output should not be null'); + Assert::assertStringContainsString($tableNode->getRowsHash()['contains'], $response['task']['output']['output']); + } + /** * @Then /^user "([^"]*)" creates a poll in room "([^"]*)" with (\d+)(?: \((v1)\))?$/ * @@ -3881,6 +3923,31 @@ public function allowGuestAccountsCreation(): void { $this->setCurrentUser($currentUser); } + /** + * @Given /^Fake summary task provider is enabled$/ + */ + public function enableTestingApp(): void { + $currentUser = $this->setCurrentUser('admin'); + + // save old state and restore at the end + $this->sendRequest('GET', '/cloud/apps?filter=enabled'); + $this->assertStatusCode($this->response, 200); + $data = $this->getDataFromResponse($this->response); + $this->testingAppWasEnabled[$this->currentServer] = in_array('testing', $data['apps'], true); + + if (!$this->testingAppWasEnabled[$this->currentServer]) { + $this->runOcc(['app:enable', 'testing']); + } + + $this->runOcc(['config:app:get', 'core', 'ai.taskprocessing_provider_preferences']); + $this->taskProcessingProviderPreference[$this->currentServer] = $this->lastStdOut; + $preferences = json_decode($this->lastStdOut ?: '[]', true, flags: JSON_THROW_ON_ERROR); + $preferences['core:text2text:summary'] = 'testing-text2text-summary'; + $this->runOcc(['config:app:set', 'core', 'ai.taskprocessing_provider_preferences', '--value', json_encode($preferences)]); + + $this->setCurrentUser($currentUser); + } + /** * @BeforeScenario * @AfterScenario @@ -3909,13 +3976,13 @@ public function resetSpreedAppData() { /** * @AfterScenario */ - public function resetGuestsAppState() { + public function resetAppsState() { foreach (['LOCAL', 'REMOTE'] as $server) { $this->usingServer($server); if ($this->guestsAppWasEnabled[$server] === null) { // Guests app was not touched - return; + continue; } $currentUser = $this->setCurrentUser('admin'); @@ -3937,6 +4004,30 @@ public function resetGuestsAppState() { $this->guestsAppWasEnabled[$server] = null; } + + foreach (['LOCAL', 'REMOTE'] as $server) { + $this->usingServer($server); + + if ($this->testingAppWasEnabled[$server] === null) { + // Testing app was not touched + continue; + } + + $currentUser = $this->setCurrentUser('admin'); + + if ($this->taskProcessingProviderPreference[$server]) { + $this->runOcc(['config:app:set', 'core', 'ai.taskprocessing_provider_preferences', '--value', $this->taskProcessingProviderPreference[$server]]); + } else { + $this->runOcc(['config:app:delete', 'core', 'ai.taskprocessing_provider_preferences']); + } + + // restore app's enabled state + $this->sendRequest($this->testingAppWasEnabled[$server] ? 'POST' : 'DELETE', '/cloud/apps/testing'); + + $this->setCurrentUser($currentUser); + + $this->testingAppWasEnabled[$server] = null; + } } /* @@ -4762,12 +4853,16 @@ public function userShareLastNotificationFile(string $user, string $firstLast, s } /** - * @When /^(force run|run) "([^"]*)" background jobs$/ + * @When /^(force run|run|repeating run) "([^"]*)" background jobs$/ */ - public function runReminderBackgroundJobs(string $useForce, string $class): void { + public function runReminderBackgroundJobs(string $useForce, string $class, bool $repeated = false): void { $this->runOcc(['background-job:list', '--output=json_pretty', '--class=' . $class]); $list = json_decode($this->lastStdOut, true, 512, JSON_THROW_ON_ERROR); + if ($repeated && empty($list)) { + return; + } + Assert::assertNotEmpty($list, 'List of ' . $class . ' should not be empty'); foreach ($list as $job) { @@ -4781,6 +4876,10 @@ public function runReminderBackgroundJobs(string $useForce, string $class): void throw new \RuntimeException($this->lastStdErr); } } + + if ($useForce === 'repeating run') { + $this->runReminderBackgroundJobs($useForce, $class, true); + } } /** diff --git a/tests/integration/features/chat-4/summary.feature b/tests/integration/features/chat-4/summary.feature new file mode 100644 index 00000000000..14a4dd1476f --- /dev/null +++ b/tests/integration/features/chat-4/summary.feature @@ -0,0 +1,23 @@ +Feature: chat-4/summary + Background: + Given user "participant1" exists + Given user "participant2" exists + + Scenario: Request a chat summary + Given Fake summary task provider is enabled + And the following spreed app config is set + | ai_unread_summary_batch_size | 3 | + And user "participant1" creates room "room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" adds user "participant2" to room "room" with 200 (v4) + When user "participant1" sends message "Message 1" to room "room" with 201 + And user "participant1" sends message "Message 2" to room "room" with 201 + And user "participant1" sends message "Message 3" to room "room" with 201 + And user "participant1" sends message "Message 4" to room "room" with 201 + And user "participant2" requests summary for "room" starting from "Message 1" with 201 + | nextOffset | Message 3 | + And repeating run "OC\TaskProcessing\SynchronousBackgroundJob" background jobs + Then user "participant2" receives summary for "room" with 200 + | contains | This is a fake summary | + And user "participant2" requests summary for "room" starting from "Message 3" with 201 From 7c8a1dbbc9ba1399d4bf96f8a5aa2946da6ab039 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Thu, 14 Nov 2024 15:50:40 +0100 Subject: [PATCH 4/5] feat(chat): Expose summary-threshold in capabilities Signed-off-by: Joas Schilling --- docs/capabilities.md | 1 + lib/Capabilities.php | 2 ++ lib/ResponseDefinitions.php | 1 + openapi-administration.json | 8 +++++++- openapi-backend-recording.json | 8 +++++++- openapi-backend-signaling.json | 8 +++++++- openapi-backend-sipbridge.json | 8 +++++++- openapi-bots.json | 8 +++++++- openapi-federation.json | 8 +++++++- openapi-full.json | 8 +++++++- openapi.json | 8 +++++++- src/__mocks__/capabilities.ts | 2 ++ src/types/openapi/openapi-administration.ts | 2 ++ src/types/openapi/openapi-backend-recording.ts | 2 ++ src/types/openapi/openapi-backend-signaling.ts | 2 ++ src/types/openapi/openapi-backend-sipbridge.ts | 2 ++ src/types/openapi/openapi-bots.ts | 2 ++ src/types/openapi/openapi-federation.ts | 2 ++ src/types/openapi/openapi-full.ts | 2 ++ src/types/openapi/openapi.ts | 2 ++ tests/php/CapabilitiesTest.php | 2 ++ 21 files changed, 80 insertions(+), 8 deletions(-) diff --git a/docs/capabilities.md b/docs/capabilities.md index e9590648870..460638a6925 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -161,5 +161,6 @@ * `talk-polls-drafts` - Whether moderators can store and retrieve poll drafts * `download-call-participants` - Whether the endpoints for moderators to download the call participants is available * `chat-summary-api` (local) - Whether the endpoint to get summarized chat messages in a conversation is available +* `config => chat => summary-threshold` (local) - Number of unread messages that should exist to show a "Generate summary" option * `config => call => start-without-media` (local) - Boolean, whether media should be disabled when starting or joining a conversation * `config => call => max-duration` - Integer, maximum call duration in seconds. Please note that this should only be used with system cron and with a reasonable high value, due to the expended duration until the background job ran. diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 8f71b405494..e2c03f54da1 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -144,6 +144,7 @@ class Capabilities implements IPublicCapability { 'read-privacy', 'has-translation-providers', 'typing-privacy', + 'summary-threshold', ], 'conversations' => [ 'can-create', @@ -217,6 +218,7 @@ public function getCapabilities(): array { 'read-privacy' => Participant::PRIVACY_PUBLIC, 'has-translation-providers' => $this->translationManager->hasProviders(), 'typing-privacy' => Participant::PRIVACY_PUBLIC, + 'summary-threshold' => 100, ], 'conversations' => [ 'can-create' => $user instanceof IUser && !$this->talkConfig->isNotAllowedToCreateConversations($user) diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 9722340e49d..5098e9f7e7a 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -354,6 +354,7 @@ * read-privacy: int, * has-translation-providers: bool, * typing-privacy: int, + * summary-threshold: positive-int, * }, * conversations: array{ * can-create: bool, diff --git a/openapi-administration.json b/openapi-administration.json index fa4e80bed78..44a2bb76f6d 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -204,7 +204,8 @@ "max-length", "read-privacy", "has-translation-providers", - "typing-privacy" + "typing-privacy", + "summary-threshold" ], "properties": { "max-length": { @@ -221,6 +222,11 @@ "typing-privacy": { "type": "integer", "format": "int64" + }, + "summary-threshold": { + "type": "integer", + "format": "int64", + "minimum": 1 } } }, diff --git a/openapi-backend-recording.json b/openapi-backend-recording.json index f6b1d452627..36b875bad9b 100644 --- a/openapi-backend-recording.json +++ b/openapi-backend-recording.json @@ -137,7 +137,8 @@ "max-length", "read-privacy", "has-translation-providers", - "typing-privacy" + "typing-privacy", + "summary-threshold" ], "properties": { "max-length": { @@ -154,6 +155,11 @@ "typing-privacy": { "type": "integer", "format": "int64" + }, + "summary-threshold": { + "type": "integer", + "format": "int64", + "minimum": 1 } } }, diff --git a/openapi-backend-signaling.json b/openapi-backend-signaling.json index ad42624f290..b2676e0453a 100644 --- a/openapi-backend-signaling.json +++ b/openapi-backend-signaling.json @@ -137,7 +137,8 @@ "max-length", "read-privacy", "has-translation-providers", - "typing-privacy" + "typing-privacy", + "summary-threshold" ], "properties": { "max-length": { @@ -154,6 +155,11 @@ "typing-privacy": { "type": "integer", "format": "int64" + }, + "summary-threshold": { + "type": "integer", + "format": "int64", + "minimum": 1 } } }, diff --git a/openapi-backend-sipbridge.json b/openapi-backend-sipbridge.json index 5df0b9eeb46..ff867c2ab9d 100644 --- a/openapi-backend-sipbridge.json +++ b/openapi-backend-sipbridge.json @@ -180,7 +180,8 @@ "max-length", "read-privacy", "has-translation-providers", - "typing-privacy" + "typing-privacy", + "summary-threshold" ], "properties": { "max-length": { @@ -197,6 +198,11 @@ "typing-privacy": { "type": "integer", "format": "int64" + }, + "summary-threshold": { + "type": "integer", + "format": "int64", + "minimum": 1 } } }, diff --git a/openapi-bots.json b/openapi-bots.json index 8f24560fe4b..a8a7508b63b 100644 --- a/openapi-bots.json +++ b/openapi-bots.json @@ -137,7 +137,8 @@ "max-length", "read-privacy", "has-translation-providers", - "typing-privacy" + "typing-privacy", + "summary-threshold" ], "properties": { "max-length": { @@ -154,6 +155,11 @@ "typing-privacy": { "type": "integer", "format": "int64" + }, + "summary-threshold": { + "type": "integer", + "format": "int64", + "minimum": 1 } } }, diff --git a/openapi-federation.json b/openapi-federation.json index 1b2274d22c7..51084c5d88c 100644 --- a/openapi-federation.json +++ b/openapi-federation.json @@ -180,7 +180,8 @@ "max-length", "read-privacy", "has-translation-providers", - "typing-privacy" + "typing-privacy", + "summary-threshold" ], "properties": { "max-length": { @@ -197,6 +198,11 @@ "typing-privacy": { "type": "integer", "format": "int64" + }, + "summary-threshold": { + "type": "integer", + "format": "int64", + "minimum": 1 } } }, diff --git a/openapi-full.json b/openapi-full.json index 25e6768594a..3438c426289 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -356,7 +356,8 @@ "max-length", "read-privacy", "has-translation-providers", - "typing-privacy" + "typing-privacy", + "summary-threshold" ], "properties": { "max-length": { @@ -373,6 +374,11 @@ "typing-privacy": { "type": "integer", "format": "int64" + }, + "summary-threshold": { + "type": "integer", + "format": "int64", + "minimum": 1 } } }, diff --git a/openapi.json b/openapi.json index 6fc2796ac7a..d068d3f0ae3 100644 --- a/openapi.json +++ b/openapi.json @@ -297,7 +297,8 @@ "max-length", "read-privacy", "has-translation-providers", - "typing-privacy" + "typing-privacy", + "summary-threshold" ], "properties": { "max-length": { @@ -314,6 +315,11 @@ "typing-privacy": { "type": "integer", "format": "int64" + }, + "summary-threshold": { + "type": "integer", + "format": "int64", + "minimum": 1 } } }, diff --git a/src/__mocks__/capabilities.ts b/src/__mocks__/capabilities.ts index 4064044190f..7eba0bd7ff1 100644 --- a/src/__mocks__/capabilities.ts +++ b/src/__mocks__/capabilities.ts @@ -125,6 +125,7 @@ export const mockedCapabilities: Capabilities = { 'read-privacy': 0, 'has-translation-providers': true, 'typing-privacy': 0, + 'summary-threshold': 100, }, conversations: { 'can-create': true, @@ -156,6 +157,7 @@ export const mockedCapabilities: Capabilities = { 'read-privacy', 'has-translation-providers', 'typing-privacy', + 'summary-threshold', ], conversations: [ 'can-create', diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index e960be1a078..fd9d4c7af15 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -240,6 +240,8 @@ export type components = { "has-translation-providers": boolean; /** Format: int64 */ "typing-privacy": number; + /** Format: int64 */ + "summary-threshold": number; }; conversations: { "can-create": boolean; diff --git a/src/types/openapi/openapi-backend-recording.ts b/src/types/openapi/openapi-backend-recording.ts index 63f3a3b8602..48a578331d9 100644 --- a/src/types/openapi/openapi-backend-recording.ts +++ b/src/types/openapi/openapi-backend-recording.ts @@ -74,6 +74,8 @@ export type components = { "has-translation-providers": boolean; /** Format: int64 */ "typing-privacy": number; + /** Format: int64 */ + "summary-threshold": number; }; conversations: { "can-create": boolean; diff --git a/src/types/openapi/openapi-backend-signaling.ts b/src/types/openapi/openapi-backend-signaling.ts index 54bf7d5d98b..d5381da24e9 100644 --- a/src/types/openapi/openapi-backend-signaling.ts +++ b/src/types/openapi/openapi-backend-signaling.ts @@ -60,6 +60,8 @@ export type components = { "has-translation-providers": boolean; /** Format: int64 */ "typing-privacy": number; + /** Format: int64 */ + "summary-threshold": number; }; conversations: { "can-create": boolean; diff --git a/src/types/openapi/openapi-backend-sipbridge.ts b/src/types/openapi/openapi-backend-sipbridge.ts index 84e84856e36..c4a985245e2 100644 --- a/src/types/openapi/openapi-backend-sipbridge.ts +++ b/src/types/openapi/openapi-backend-sipbridge.ts @@ -155,6 +155,8 @@ export type components = { "has-translation-providers": boolean; /** Format: int64 */ "typing-privacy": number; + /** Format: int64 */ + "summary-threshold": number; }; conversations: { "can-create": boolean; diff --git a/src/types/openapi/openapi-bots.ts b/src/types/openapi/openapi-bots.ts index 5e29b8e7795..6954a8e9e3b 100644 --- a/src/types/openapi/openapi-bots.ts +++ b/src/types/openapi/openapi-bots.ts @@ -78,6 +78,8 @@ export type components = { "has-translation-providers": boolean; /** Format: int64 */ "typing-privacy": number; + /** Format: int64 */ + "summary-threshold": number; }; conversations: { "can-create": boolean; diff --git a/src/types/openapi/openapi-federation.ts b/src/types/openapi/openapi-federation.ts index b498f99f0f7..5f91c1ddc2a 100644 --- a/src/types/openapi/openapi-federation.ts +++ b/src/types/openapi/openapi-federation.ts @@ -186,6 +186,8 @@ export type components = { "has-translation-providers": boolean; /** Format: int64 */ "typing-privacy": number; + /** Format: int64 */ + "summary-threshold": number; }; conversations: { "can-create": boolean; diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 02d939a1e29..361d097b962 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -1956,6 +1956,8 @@ export type components = { "has-translation-providers": boolean; /** Format: int64 */ "typing-privacy": number; + /** Format: int64 */ + "summary-threshold": number; }; conversations: { "can-create": boolean; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index ce61b47bc9c..77f55ac5a6d 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1453,6 +1453,8 @@ export type components = { "has-translation-providers": boolean; /** Format: int64 */ "typing-privacy": number; + /** Format: int64 */ + "summary-threshold": number; }; conversations: { "can-create": boolean; diff --git a/tests/php/CapabilitiesTest.php b/tests/php/CapabilitiesTest.php index 4a8e758cc0e..05c7aead51a 100644 --- a/tests/php/CapabilitiesTest.php +++ b/tests/php/CapabilitiesTest.php @@ -147,6 +147,7 @@ public function testGetCapabilitiesGuest(): void { 'read-privacy' => 0, 'has-translation-providers' => false, 'typing-privacy' => 0, + 'summary-threshold' => 100, ], 'conversations' => [ 'can-create' => false, @@ -277,6 +278,7 @@ public function testGetCapabilitiesUserAllowed(bool $isNotAllowed, bool $canCrea 'read-privacy' => $readPrivacy, 'has-translation-providers' => false, 'typing-privacy' => 0, + 'summary-threshold' => 100, ], 'conversations' => [ 'can-create' => $canCreate, From 693a4595aabe02e3b17d87e38adb52ed97b146f5 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Fri, 15 Nov 2024 09:03:22 +0100 Subject: [PATCH 5/5] fix: Manually copy new method from Nextcloud 31 Signed-off-by: Joas Schilling --- lib/Controller/ChatController.php | 22 ++++++++++++++++++--- tests/php/Controller/ChatControllerTest.php | 4 ---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/lib/Controller/ChatController.php b/lib/Controller/ChatController.php index 3c0c10b3cd5..e9f74a10321 100644 --- a/lib/Controller/ChatController.php +++ b/lib/Controller/ChatController.php @@ -65,7 +65,6 @@ use OCP\IRequest; use OCP\IUserManager; use OCP\RichObjectStrings\InvalidObjectExeption; -use OCP\RichObjectStrings\IRichTextFormatter; use OCP\RichObjectStrings\IValidator; use OCP\Security\ITrustedDomainHelper; use OCP\Security\RateLimiting\IRateLimitExceededException; @@ -123,7 +122,6 @@ public function __construct( protected Authenticator $federationAuthenticator, protected ProxyCacheMessageService $pcmService, protected Notifier $notifier, - protected IRichTextFormatter $richTextFormatter, protected ITaskProcessingManager $taskProcessingManager, protected IAppConfig $appConfig, protected LoggerInterface $logger, @@ -570,7 +568,7 @@ public function summarizeChat( continue; } - $parsedMessage = $this->richTextFormatter->richToParsed( + $parsedMessage = $this->richToParsed( $message->getMessage(), $message->getMessageParameters(), ); @@ -634,6 +632,24 @@ public function summarizeChat( return new DataResponse($data, Http::STATUS_CREATED); } + /** + * Function is copied from Nextcloud 31 \OCP\RichObjectStrings\IRichTextFormatter::richToParsed + * @deprecated + */ + protected function richToParsed(string $message, array $parameters): string { + $placeholders = []; + $replacements = []; + foreach ($parameters as $placeholder => $parameter) { + $placeholders[] = '{' . $placeholder . '}'; + $replacements[] = match($parameter['type']) { + 'user' => '@' . $parameter['name'], + 'file' => $parameter['path'] ?? $parameter['name'], + default => $parameter['name'], + }; + } + return str_replace($placeholders, $replacements, $message); + } + /** * @return DataResponse */ diff --git a/tests/php/Controller/ChatControllerTest.php b/tests/php/Controller/ChatControllerTest.php index 37d6cc41360..baf9bcad026 100644 --- a/tests/php/Controller/ChatControllerTest.php +++ b/tests/php/Controller/ChatControllerTest.php @@ -44,7 +44,6 @@ use OCP\IRequest; use OCP\IUser; use OCP\IUserManager; -use OCP\RichObjectStrings\IRichTextFormatter; use OCP\RichObjectStrings\IValidator; use OCP\Security\ITrustedDomainHelper; use OCP\TaskProcessing\IManager as ITaskProcessingManager; @@ -85,7 +84,6 @@ class ChatControllerTest extends TestCase { private Authenticator&MockObject $federationAuthenticator; private ProxyCacheMessageService&MockObject $pcmService; private Notifier&MockObject $notifier; - private IRichTextFormatter&MockObject $richTextFormatter; private ITaskProcessingManager&MockObject $taskProcessingManager; private IAppConfig&MockObject $appConfig; private LoggerInterface&MockObject $logger; @@ -129,7 +127,6 @@ public function setUp(): void { $this->federationAuthenticator = $this->createMock(Authenticator::class); $this->pcmService = $this->createMock(ProxyCacheMessageService::class); $this->notifier = $this->createMock(Notifier::class); - $this->richTextFormatter = $this->createMock(IRichTextFormatter::class); $this->taskProcessingManager = $this->createMock(ITaskProcessingManager::class); $this->appConfig = $this->createMock(IAppConfig::class); $this->logger = $this->createMock(LoggerInterface::class); @@ -179,7 +176,6 @@ private function recreateChatController() { $this->federationAuthenticator, $this->pcmService, $this->notifier, - $this->richTextFormatter, $this->taskProcessingManager, $this->appConfig, $this->logger,