diff --git a/appinfo/info.xml b/appinfo/info.xml index 72fcab9716c..41454002c9d 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -18,7 +18,7 @@ * 🌉 **Sync with other chat solutions** With [Matterbridge](https://github.com/42wim/matterbridge/) being integrated in Talk, you can easily sync a lot of other chat solutions to Nextcloud Talk and vice-versa. ]]> - 22.0.0-beta.1 + 22.0.0-beta.1.1 agpl Anna Larch diff --git a/docs/capabilities.md b/docs/capabilities.md index 1674e1ef432..d4ceabbcfb5 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -196,3 +196,4 @@ ## 22 * `threads` - Whether the chat supports threads +* `config => call => live-transcription` - Whether live transcription is supported in calls diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 3f799e0b672..23e2776fac0 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -9,6 +9,7 @@ namespace OCA\Talk; use OCA\Talk\Chat\ChatManager; +use OCA\Talk\Service\LiveTranscriptionService; use OCP\App\IAppManager; use OCP\AppFramework\Services\IAppConfig; use OCP\Capabilities\IPublicCapability; @@ -208,6 +209,7 @@ public function __construct( protected IAppManager $appManager, protected ITranslationManager $translationManager, protected ITaskProcessingManager $taskProcessingManager, + protected LiveTranscriptionService $liveTranscriptionService, ICacheFactory $cacheFactory, ) { $this->talkCache = $cacheFactory->createLocal('talk::'); @@ -249,6 +251,8 @@ public function getCapabilities(): array { 'max-duration' => $this->appConfig->getAppValueInt('max_call_duration'), 'blur-virtual-background' => $this->talkConfig->getBlurVirtualBackground($user?->getUID()), 'end-to-end-encryption' => $this->talkConfig->isCallEndToEndEncryptionEnabled(), + 'live-transcription' => $this->talkConfig->getSignalingMode() === Config::SIGNALING_EXTERNAL + && $this->liveTranscriptionService->isLiveTranscriptionAppEnabled(), ], 'chat' => [ 'max-length' => ChatManager::MAX_CHAT_LENGTH, diff --git a/lib/Controller/LiveTranscriptionController.php b/lib/Controller/LiveTranscriptionController.php new file mode 100644 index 00000000000..f8e549886cc --- /dev/null +++ b/lib/Controller/LiveTranscriptionController.php @@ -0,0 +1,153 @@ +|DataResponse + * + * 200: Live transcription enabled successfully + * 400: The external app "live_transcription" is not available + * 400: The participant is not in the call + */ + #[PublicPage] + #[RequireCallEnabled] + #[RequireModeratorOrNoLobby] + #[RequireParticipant] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/live-transcription/{token}', requirements: [ + 'apiVersion' => '(v1)', + 'token' => '[a-z0-9]{4,30}', + ])] + public function enable(): DataResponse { + if ($this->room->getCallFlag() === Participant::FLAG_DISCONNECTED) { + return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST); + } + + if ($this->participant->getSession() && $this->participant->getSession()->getInCall() === Participant::FLAG_DISCONNECTED) { + return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST); + } + + try { + $this->liveTranscriptionService->enable($this->room, $this->participant); + } catch (LiveTranscriptionAppNotEnabledException $e) { + return new DataResponse(['error' => 'app'], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse(null); + } + + /** + * Disable the live transcription + * + * @return DataResponse|DataResponse + * + * 200: Live transcription stopped successfully + * 400: The external app "live_transcription" is not available + * 400: The participant is not in the call + */ + #[PublicPage] + #[RequireModeratorOrNoLobby] + #[RequireParticipant] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/live-transcription/{token}', requirements: [ + 'apiVersion' => '(v1)', + 'token' => '[a-z0-9]{4,30}', + ])] + public function disable(): DataResponse { + if ($this->room->getCallFlag() === Participant::FLAG_DISCONNECTED) { + return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST); + } + + if ($this->participant->getSession() && $this->participant->getSession()->getInCall() === Participant::FLAG_DISCONNECTED) { + return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST); + } + + try { + $this->liveTranscriptionService->disable($this->room, $this->participant); + } catch (LiveTranscriptionAppNotEnabledException $e) { + return new DataResponse(['error' => 'app'], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse(null); + } + + /** + * Get available languages for live transcriptions + * + * @return DataResponse, array{}>|DataResponse + * + * 200: Available languages got successfully + * 400: The external app "live_transcription" is not available + */ + #[PublicPage] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/live-transcription/languages', requirements: [ + 'apiVersion' => '(v1)', + ])] + public function getAvailableLanguages(): DataResponse { + try { + $languages = $this->liveTranscriptionService->getAvailableLanguages(); + } catch (LiveTranscriptionAppNotEnabledException $e) { + return new DataResponse(['error' => 'app'], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse($languages); + } + + /** + * Set language for live transcriptions + * + * @param string $languageId the ID of the language to set + * @return DataResponse|DataResponse + * + * 200: Language set successfully + * 400: The external app "live_transcription" is not available + * 403: Participant is not a moderator + */ + #[PublicPage] + #[RequireModeratorParticipant] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/live-transcription/{token}/language', requirements: [ + 'apiVersion' => '(v1)', + 'token' => '[a-z0-9]{4,30}', + ])] + public function setLanguage(string $languageId): DataResponse { + try { + $this->liveTranscriptionService->setLanguage($this->room, $languageId); + } catch (LiveTranscriptionAppNotEnabledException $e) { + return new DataResponse(['error' => 'app'], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse(null); + } +} diff --git a/lib/Events/ARoomModifiedEvent.php b/lib/Events/ARoomModifiedEvent.php index 72a8f415df1..ede725f34ae 100644 --- a/lib/Events/ARoomModifiedEvent.php +++ b/lib/Events/ARoomModifiedEvent.php @@ -24,6 +24,7 @@ abstract class ARoomModifiedEvent extends ARoomEvent { public const PROPERTY_IN_CALL = 'inCall'; public const PROPERTY_LISTABLE = 'listable'; public const PROPERTY_LOBBY = 'lobby'; + public const PROPERTY_LIVE_TRANSCRIPTION_LANGUAGE_ID = 'liveTranscriptionLanguageId'; public const PROPERTY_MESSAGE_EXPIRATION = 'messageExpiration'; public const PROPERTY_MENTION_PERMISSIONS = 'mentionPermissions'; public const PROPERTY_NAME = 'name'; diff --git a/lib/Exceptions/LiveTranscriptionAppAPIException.php b/lib/Exceptions/LiveTranscriptionAppAPIException.php new file mode 100644 index 00000000000..c7cafc1938f --- /dev/null +++ b/lib/Exceptions/LiveTranscriptionAppAPIException.php @@ -0,0 +1,12 @@ +response; + } +} diff --git a/lib/Manager.php b/lib/Manager.php index 8087cf1eaed..a80b71f3de9 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -122,6 +122,7 @@ public function createRoomObjectFromData(array $data): Room { 'recording_consent' => 0, 'has_federation' => 0, 'mention_permissions' => 0, + 'transcription_language' => '', ], $data)); } @@ -191,6 +192,7 @@ public function createRoomObject(array $row): Room { (int)$row['recording_consent'], (int)$row['has_federation'], (int)$row['mention_permissions'], + (string)$row['transcription_language'], ); } diff --git a/lib/Migration/Version22000Date20250813122342.php b/lib/Migration/Version22000Date20250813122342.php new file mode 100644 index 00000000000..66baac5839a --- /dev/null +++ b/lib/Migration/Version22000Date20250813122342.php @@ -0,0 +1,42 @@ +getTable('talk_rooms'); + if (!$table->hasColumn('transcription_language')) { + $table->addColumn('transcription_language', Types::STRING, [ + 'notnull' => false, + 'default' => '', + 'length' => 16, + ]); + } + + return $schema; + } +} diff --git a/lib/Model/SelectHelper.php b/lib/Model/SelectHelper.php index 48a62d2d247..304c614b949 100644 --- a/lib/Model/SelectHelper.php +++ b/lib/Model/SelectHelper.php @@ -45,6 +45,7 @@ public function selectRoomsTable(IQueryBuilder $query, string $alias = 'r'): voi ->addSelect($alias . 'recording_consent') ->addSelect($alias . 'has_federation') ->addSelect($alias . 'mention_permissions') + ->addSelect($alias . 'transcription_language') ->selectAlias($alias . 'id', 'r_id'); } diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index ef63deac58a..ca60a0617d0 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -308,6 +308,8 @@ * lastReadMessage: int, * // Listable scope for the room (only available with `listable-rooms` capability) * listable: int, + * // ID of the language to use for live transcriptions in the room, + * liveTranscriptionLanguageId: string, * // Webinar lobby restriction (0-1), if the participant is a moderator they can always join the conversation (only available with `webinary-lobby` capability) (See [Webinar lobby states](https://nextcloud-talk.readthedocs.io/en/latest/constants#webinar-lobby-states)) * lobbyState: int, * // Timestamp when the lobby will be automatically disabled (only available with `webinary-lobby` capability) @@ -493,6 +495,7 @@ * max-duration: int, * blur-virtual-background: bool, * end-to-end-encryption: bool, + * live-transcription: bool, * }, * chat: array{ * max-length: int, @@ -531,6 +534,14 @@ * config-local: array>, * version: string, * } + * + * @psalm-type TalkLiveTranscriptionLanguage = array{ + * name: string, + * metadata: array{ + * separator: string, + * rtl: bool, + * }, + * } */ class ResponseDefinitions { } diff --git a/lib/Room.php b/lib/Room.php index 8e8899cb91e..4795f4f4094 100644 --- a/lib/Room.php +++ b/lib/Room.php @@ -142,6 +142,7 @@ public function __construct( private int $recordingConsent, private int $hasFederation, private int $mentionPermissions, + private string $liveTranscriptionLanguageId, ) { } @@ -608,4 +609,12 @@ public function setObjectId(string $objectId): void { public function setObjectType(string $objectType): void { $this->objectType = $objectType; } + + public function getLiveTranscriptionLanguageId(): string { + return $this->liveTranscriptionLanguageId; + } + + public function setLiveTranscriptionLanguageId(string $liveTranscriptionLanguageId): void { + $this->liveTranscriptionLanguageId = $liveTranscriptionLanguageId; + } } diff --git a/lib/Service/LiveTranscriptionService.php b/lib/Service/LiveTranscriptionService.php new file mode 100644 index 00000000000..8a8bb163121 --- /dev/null +++ b/lib/Service/LiveTranscriptionService.php @@ -0,0 +1,254 @@ +getAppApiPublicFunctions(); + } + } catch (LiveTranscriptionAppAPIException $e) { + return false; + } + + $exApp = $appApiPublicFunctions->getExApp('live_transcription'); + if ($exApp === null || !$exApp['enabled']) { + return false; + } + + return true; + } + + /** + * @throws LiveTranscriptionAppAPIException if app_api is not enabled or the + * public functions could not be + * got. + */ + private function getAppApiPublicFunctions(): object { + if (!$this->appManager->isEnabledForUser('app_api')) { + throw new LiveTranscriptionAppAPIException('app-api'); + } + + try { + $appApiPublicFunctions = Server::get(PublicFunctions::class); + } catch (ContainerExceptionInterface|NotFoundExceptionInterface $e) { + throw new LiveTranscriptionAppAPIException('app-api-functions'); + } + + return $appApiPublicFunctions; + } + + /** + * @throws LiveTranscriptionAppNotEnabledException if the external app + * "live_transcription" is + * not enabled. + * @throws LiveTranscriptionAppAPIException if the request could not be sent + * to the app or the response could + * not be processed. + * @throws LiveTranscriptionAppResponseException if the request itself + * succeeded but the app + * responded with an error. + */ + public function enable(Room $room, Participant $participant): void { + $parameters = [ + 'roomToken' => $room->getToken(), + 'ncSessionId' => $participant->getSession()->getSessionId(), + 'enable' => true, + ]; + + $languageId = $room->getLiveTranscriptionLanguageId(); + if (!empty($languageId)) { + $parameters['langId'] = $languageId; + } + + $this->requestToExAppLiveTranscription('POST', '/api/v1/call/transcribe', $parameters); + } + + /** + * @throws LiveTranscriptionAppNotEnabledException if the external app + * "live_transcription" is + * not enabled. + * @throws LiveTranscriptionAppAPIException if the request could not be sent + * to the app or the response could + * not be processed. + * @throws LiveTranscriptionAppResponseException if the request itself + * succeeded but the app + * responded with an error. + */ + public function disable(Room $room, Participant $participant): void { + $parameters = [ + 'roomToken' => $room->getToken(), + 'ncSessionId' => $participant->getSession()->getSessionId(), + 'enable' => false, + ]; + + $this->requestToExAppLiveTranscription('POST', '/api/v1/call/transcribe', $parameters); + } + + /** + * @throws LiveTranscriptionAppNotEnabledException if the external app + * "live_transcription" is + * not enabled. + * @throws LiveTranscriptionAppAPIException if the request could not be sent + * to the app or the response could + * not be processed. + * @throws LiveTranscriptionAppResponseException if the request itself + * succeeded but the app + * responded with an error. + */ + public function getAvailableLanguages(): array { + $languages = $this->requestToExAppLiveTranscription('GET', '/api/v1/languages'); + if ($languages === null) { + $this->logger->error('Request to live_transcription (ExApp) failed: list of available languages is null'); + + throw new LiveTranscriptionAppAPIException('response-null-language-list'); + } + + return $languages; + } + + /** + * @throws LiveTranscriptionAppNotEnabledException if the external app + * "live_transcription" is + * not enabled. + * @throws LiveTranscriptionAppAPIException if the request could not be sent + * to the app or the response could + * not be processed. + * @throws LiveTranscriptionAppResponseException if the request itself + * succeeded but the app + * responded with an error. + */ + public function setLanguage(Room $room, string $languageId): void { + $parameters = [ + 'roomToken' => $room->getToken(), + 'langId' => ! empty($languageId) ? $languageId : 'es', + ]; + + try { + $this->requestToExAppLiveTranscription('POST', '/api/v1/call/set-language', $parameters); + } catch (LiveTranscriptionAppResponseException $e) { + // If there is no active transcription continue setting the language + // in the room. In any other case, abort. + if ($e->getResponse()->getStatusCode() !== 404) { + throw $e; + } + } + + $this->roomService->setLiveTranscriptionLanguageId($room, $languageId); + } + + /** + * @throws LiveTranscriptionAppNotEnabledException if the external app + * "live_transcription" is + * not enabled. + * @throws LiveTranscriptionAppAPIException if the request could not be sent + * to the app or the response could + * not be processed. + * @throws LiveTranscriptionAppResponseException if the request itself + * succeeded but the app + * responded with an error. + */ + private function requestToExAppLiveTranscription(string $method, string $route, array $parameters = []): ?array { + try { + $appApiPublicFunctions = $this->getAppApiPublicFunctions(); + } catch (LiveTranscriptionAppAPIException $e) { + if ($e->getMessage() === 'app-api') { + $this->logger->error('AppAPI is not enabled'); + } elseif ($e->getMessage() === 'app-api-functions') { + $this->logger->error('Could not get AppAPI public functions', ['exception' => $e]); + } + + throw new LiveTranscriptionAppNotEnabledException($e->getMessage()); + } + + if (!$this->isLiveTranscriptionAppEnabled($appApiPublicFunctions)) { + $this->logger->error('live_transcription (ExApp) is not enabled'); + + throw new LiveTranscriptionAppNotEnabledException('live-transcription-app'); + } + + $response = $appApiPublicFunctions->exAppRequest( + 'live_transcription', + $route, + $this->userId, + $method, + $parameters, + ); + + if (is_array($response) && isset($response['error'])) { + $this->logger->error('Request to live_transcription (ExApp) failed: ' . $response['error']); + + throw new LiveTranscriptionAppAPIException('response-error'); + } + + if (is_array($response)) { + // AppApi only uses array responses for errors, so this should never + // happen. + $this->logger->error('Request to live_transcription (ExApp) failed: response is not a valid response object'); + + throw new LiveTranscriptionAppAPIException('response-invalid-object'); + } + + $responseContentType = $response->getHeader('Content-Type'); + if (strpos($responseContentType, 'application/json') !== false) { + $body = $response->getBody(); + if (!is_string($body)) { + $this->logger->error('Request to live_transcription (ExApp) failed: response body is not a string, but content type is application/json', ['response' => $response]); + + throw new LiveTranscriptionAppAPIException('response-content-type'); + } + + $decodedBody = json_decode($body, true); + } else { + $decodedBody = ['response' => $response->getBody()]; + } + + if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { + $this->logger->error('live_transcription (ExApp) returned an error', [ + 'status-code' => $response->getStatusCode(), + 'response' => $decodedBody, + 'method' => $method, + 'route' => $route, + 'parameters' => $parameters, + ]); + + $exceptionMessage = 'response-status-code'; + if (is_array($decodedBody) && isset($decodedBody['error'])) { + $exceptionMessage .= ': ' . $decodedBody['error']; + } + throw new LiveTranscriptionAppResponseException($exceptionMessage, 0, null, $response); + } + + return $decodedBody; + } +} diff --git a/lib/Service/RoomFormatter.php b/lib/Service/RoomFormatter.php index ca9bf25af50..058df10e7ff 100644 --- a/lib/Service/RoomFormatter.php +++ b/lib/Service/RoomFormatter.php @@ -144,6 +144,7 @@ public function formatRoomV4( 'breakoutRoomStatus' => BreakoutRoom::STATUS_STOPPED, 'recordingConsent' => $this->talkConfig->recordingConsentRequired() === RecordingService::CONSENT_REQUIRED_OPTIONAL ? $room->getRecordingConsent() : $this->talkConfig->recordingConsentRequired(), 'mentionPermissions' => Room::MENTION_PERMISSIONS_EVERYONE, + 'liveTranscriptionLanguageId' => '', 'isArchived' => false, 'isImportant' => false, 'isSensitive' => false, @@ -230,6 +231,7 @@ public function formatRoomV4( 'breakoutRoomMode' => $room->getBreakoutRoomMode(), 'breakoutRoomStatus' => $room->getBreakoutRoomStatus(), 'mentionPermissions' => $room->getMentionPermissions(), + 'liveTranscriptionLanguageId' => $room->getLiveTranscriptionLanguageId(), 'isArchived' => $attendee->isArchived(), 'isImportant' => $attendee->isImportant(), 'isSensitive' => $attendee->isSensitive(), diff --git a/lib/Service/RoomService.php b/lib/Service/RoomService.php index 481e36af472..c07bf80073e 100644 --- a/lib/Service/RoomService.php +++ b/lib/Service/RoomService.php @@ -848,6 +848,39 @@ public function setMentionPermissions(Room $room, int $newState): void { $this->dispatcher->dispatchTyped($event); } + /** + * Set the ID of the language to use for live transcriptions in the given + * room. + * + * This method is not meant to be directly used; use + * "LiveTranscriptionService::setLanguage" instead, which only sets the + * language in the room if the external app is available and the language is + * valid. + * + * @param Room $room + * @param string $newState ID of the language to set + */ + public function setLiveTranscriptionLanguageId(Room $room, string $newState): void { + $oldState = $room->getLiveTranscriptionLanguageId(); + if ($newState === $oldState) { + return; + } + + $event = new BeforeRoomModifiedEvent($room, ARoomModifiedEvent::PROPERTY_LIVE_TRANSCRIPTION_LANGUAGE_ID, $newState, $oldState); + $this->dispatcher->dispatchTyped($event); + + $update = $this->db->getQueryBuilder(); + $update->update('talk_rooms') + ->set('transcription_language', $update->createNamedParameter($newState, IQueryBuilder::PARAM_STR)) + ->where($update->expr()->eq('id', $update->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT))); + $update->executeStatement(); + + $room->setLiveTranscriptionLanguageId($newState); + + $event = new RoomModifiedEvent($room, ARoomModifiedEvent::PROPERTY_LIVE_TRANSCRIPTION_LANGUAGE_ID, $newState, $oldState); + $this->dispatcher->dispatchTyped($event); + } + public function setAssignedSignalingServer(Room $room, ?int $signalingServer): bool { $update = $this->db->getQueryBuilder(); $update->update('talk_rooms') diff --git a/openapi-administration.json b/openapi-administration.json index 45525338ff1..d9173690b15 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -155,7 +155,8 @@ "start-without-media", "max-duration", "blur-virtual-background", - "end-to-end-encryption" + "end-to-end-encryption", + "live-transcription" ], "properties": { "enabled": { @@ -215,6 +216,9 @@ }, "end-to-end-encryption": { "type": "boolean" + }, + "live-transcription": { + "type": "boolean" } } }, diff --git a/openapi-backend-recording.json b/openapi-backend-recording.json index 370ded27c58..31a0c91d343 100644 --- a/openapi-backend-recording.json +++ b/openapi-backend-recording.json @@ -88,7 +88,8 @@ "start-without-media", "max-duration", "blur-virtual-background", - "end-to-end-encryption" + "end-to-end-encryption", + "live-transcription" ], "properties": { "enabled": { @@ -148,6 +149,9 @@ }, "end-to-end-encryption": { "type": "boolean" + }, + "live-transcription": { + "type": "boolean" } } }, diff --git a/openapi-backend-signaling.json b/openapi-backend-signaling.json index cfd9985a075..0c38db9d611 100644 --- a/openapi-backend-signaling.json +++ b/openapi-backend-signaling.json @@ -88,7 +88,8 @@ "start-without-media", "max-duration", "blur-virtual-background", - "end-to-end-encryption" + "end-to-end-encryption", + "live-transcription" ], "properties": { "enabled": { @@ -148,6 +149,9 @@ }, "end-to-end-encryption": { "type": "boolean" + }, + "live-transcription": { + "type": "boolean" } } }, diff --git a/openapi-backend-sipbridge.json b/openapi-backend-sipbridge.json index 8b7eb7c890e..8e614df51f3 100644 --- a/openapi-backend-sipbridge.json +++ b/openapi-backend-sipbridge.json @@ -131,7 +131,8 @@ "start-without-media", "max-duration", "blur-virtual-background", - "end-to-end-encryption" + "end-to-end-encryption", + "live-transcription" ], "properties": { "enabled": { @@ -191,6 +192,9 @@ }, "end-to-end-encryption": { "type": "boolean" + }, + "live-transcription": { + "type": "boolean" } } }, @@ -625,6 +629,7 @@ "lastPing", "lastReadMessage", "listable", + "liveTranscriptionLanguageId", "lobbyState", "lobbyTimer", "mentionPermissions", @@ -799,6 +804,10 @@ "format": "int64", "description": "Listable scope for the room (only available with `listable-rooms` capability)" }, + "liveTranscriptionLanguageId": { + "type": "string", + "description": "ID of the language to use for live transcriptions in the room," + }, "lobbyState": { "type": "integer", "format": "int64", diff --git a/openapi-bots.json b/openapi-bots.json index 5a529fd3bcf..38f5faf0596 100644 --- a/openapi-bots.json +++ b/openapi-bots.json @@ -88,7 +88,8 @@ "start-without-media", "max-duration", "blur-virtual-background", - "end-to-end-encryption" + "end-to-end-encryption", + "live-transcription" ], "properties": { "enabled": { @@ -148,6 +149,9 @@ }, "end-to-end-encryption": { "type": "boolean" + }, + "live-transcription": { + "type": "boolean" } } }, diff --git a/openapi-federation.json b/openapi-federation.json index d47176c53b9..46a10e5187d 100644 --- a/openapi-federation.json +++ b/openapi-federation.json @@ -131,7 +131,8 @@ "start-without-media", "max-duration", "blur-virtual-background", - "end-to-end-encryption" + "end-to-end-encryption", + "live-transcription" ], "properties": { "enabled": { @@ -191,6 +192,9 @@ }, "end-to-end-encryption": { "type": "boolean" + }, + "live-transcription": { + "type": "boolean" } } }, @@ -679,6 +683,7 @@ "lastPing", "lastReadMessage", "listable", + "liveTranscriptionLanguageId", "lobbyState", "lobbyTimer", "mentionPermissions", @@ -853,6 +858,10 @@ "format": "int64", "description": "Listable scope for the room (only available with `listable-rooms` capability)" }, + "liveTranscriptionLanguageId": { + "type": "string", + "description": "ID of the language to use for live transcriptions in the room," + }, "lobbyState": { "type": "integer", "format": "int64", diff --git a/openapi-full.json b/openapi-full.json index 2bd921738db..ce4a8660b28 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -289,7 +289,8 @@ "start-without-media", "max-duration", "blur-virtual-background", - "end-to-end-encryption" + "end-to-end-encryption", + "live-transcription" ], "properties": { "enabled": { @@ -349,6 +350,9 @@ }, "end-to-end-encryption": { "type": "boolean" + }, + "live-transcription": { + "type": "boolean" } } }, @@ -1001,6 +1005,33 @@ } } }, + "LiveTranscriptionLanguage": { + "type": "object", + "required": [ + "name", + "metadata" + ], + "properties": { + "name": { + "type": "string" + }, + "metadata": { + "type": "object", + "required": [ + "separator", + "rtl" + ], + "properties": { + "separator": { + "type": "string" + }, + "rtl": { + "type": "boolean" + } + } + } + } + }, "Matterbridge": { "type": "object", "required": [ @@ -1476,6 +1507,7 @@ "lastPing", "lastReadMessage", "listable", + "liveTranscriptionLanguageId", "lobbyState", "lobbyTimer", "mentionPermissions", @@ -1650,6 +1682,10 @@ "format": "int64", "description": "Listable scope for the room (only available with `listable-rooms` capability)" }, + "liveTranscriptionLanguageId": { + "type": "string", + "description": "ID of the language to use for live transcriptions in the room," + }, "lobbyState": { "type": "integer", "format": "int64", @@ -20872,6 +20908,556 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/{token}": { + "post": { + "operationId": "live_transcription-enable", + "summary": "Enable the live transcription", + "tags": [ + "live_transcription" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "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": { + "200": { + "description": "Live transcription enabled successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "400": { + "description": "The participant is not in the call", + "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": [ + "app", + "in-call" + ] + } + } + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "live_transcription-disable", + "summary": "Disable the live transcription", + "tags": [ + "live_transcription" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "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": { + "200": { + "description": "Live transcription stopped successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "400": { + "description": "The participant is not in the call", + "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": [ + "app", + "in-call" + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/languages": { + "get": { + "operationId": "live_transcription-get-available-languages", + "summary": "Get available languages for live transcriptions", + "tags": [ + "live_transcription" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Available languages got successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/LiveTranscriptionLanguage" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "The external app \"live_transcription\" is not available", + "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": [ + "app" + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/{token}/language": { + "post": { + "operationId": "live_transcription-set-language", + "summary": "Set language for live transcriptions", + "tags": [ + "live_transcription" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "languageId" + ], + "properties": { + "languageId": { + "type": "string", + "description": "the ID of the language to set" + } + } + } + } + } + }, + "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": { + "200": { + "description": "Language set successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "400": { + "description": "The external app \"live_transcription\" is not available", + "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": [ + "app" + ] + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "Participant is not a moderator", + "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": [ + "app" + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/threads/recent": { "get": { "operationId": "thread-get-recent-active-threads", diff --git a/openapi.json b/openapi.json index 4b1ec80d59a..a71dba11ee4 100644 --- a/openapi.json +++ b/openapi.json @@ -248,7 +248,8 @@ "start-without-media", "max-duration", "blur-virtual-background", - "end-to-end-encryption" + "end-to-end-encryption", + "live-transcription" ], "properties": { "enabled": { @@ -308,6 +309,9 @@ }, "end-to-end-encryption": { "type": "boolean" + }, + "live-transcription": { + "type": "boolean" } } }, @@ -906,6 +910,33 @@ } } }, + "LiveTranscriptionLanguage": { + "type": "object", + "required": [ + "name", + "metadata" + ], + "properties": { + "name": { + "type": "string" + }, + "metadata": { + "type": "object", + "required": [ + "separator", + "rtl" + ], + "properties": { + "separator": { + "type": "string" + }, + "rtl": { + "type": "boolean" + } + } + } + } + }, "Matterbridge": { "type": "object", "required": [ @@ -1381,6 +1412,7 @@ "lastPing", "lastReadMessage", "listable", + "liveTranscriptionLanguageId", "lobbyState", "lobbyTimer", "mentionPermissions", @@ -1555,6 +1587,10 @@ "format": "int64", "description": "Listable scope for the room (only available with `listable-rooms` capability)" }, + "liveTranscriptionLanguageId": { + "type": "string", + "description": "ID of the language to use for live transcriptions in the room," + }, "lobbyState": { "type": "integer", "format": "int64", @@ -20777,6 +20813,556 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/{token}": { + "post": { + "operationId": "live_transcription-enable", + "summary": "Enable the live transcription", + "tags": [ + "live_transcription" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "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": { + "200": { + "description": "Live transcription enabled successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "400": { + "description": "The participant is not in the call", + "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": [ + "app", + "in-call" + ] + } + } + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "live_transcription-disable", + "summary": "Disable the live transcription", + "tags": [ + "live_transcription" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "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": { + "200": { + "description": "Live transcription stopped successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "400": { + "description": "The participant is not in the call", + "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": [ + "app", + "in-call" + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/languages": { + "get": { + "operationId": "live_transcription-get-available-languages", + "summary": "Get available languages for live transcriptions", + "tags": [ + "live_transcription" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Available languages got successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/LiveTranscriptionLanguage" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "The external app \"live_transcription\" is not available", + "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": [ + "app" + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/{token}/language": { + "post": { + "operationId": "live_transcription-set-language", + "summary": "Set language for live transcriptions", + "tags": [ + "live_transcription" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "languageId" + ], + "properties": { + "languageId": { + "type": "string", + "description": "the ID of the language to set" + } + } + } + } + } + }, + "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": { + "200": { + "description": "Language set successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "400": { + "description": "The external app \"live_transcription\" is not available", + "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": [ + "app" + ] + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "Participant is not a moderator", + "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": [ + "app" + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/threads/recent": { "get": { "operationId": "thread-get-recent-active-threads", diff --git a/psalm.xml b/psalm.xml index 641e397c11f..d383d3f4ab4 100644 --- a/psalm.xml +++ b/psalm.xml @@ -36,6 +36,7 @@ + @@ -61,6 +62,7 @@ + diff --git a/src/__mocks__/capabilities.ts b/src/__mocks__/capabilities.ts index 8c5d6dd7250..9773b370c01 100644 --- a/src/__mocks__/capabilities.ts +++ b/src/__mocks__/capabilities.ts @@ -150,6 +150,7 @@ export const mockedCapabilities: Capabilities = { 'max-duration': 0, 'blur-virtual-background': false, 'end-to-end-encryption': false, + 'live-transcription': false, }, chat: { 'max-length': 32000, diff --git a/src/components/CallView/BottomBar.vue b/src/components/CallView/BottomBar.vue index b1137c72446..14990fc5ed3 100644 --- a/src/components/CallView/BottomBar.vue +++ b/src/components/CallView/BottomBar.vue @@ -4,15 +4,21 @@ --> + + diff --git a/src/components/CallView/shared/TranscriptBlock.vue b/src/components/CallView/shared/TranscriptBlock.vue new file mode 100644 index 00000000000..37b17768a6e --- /dev/null +++ b/src/components/CallView/shared/TranscriptBlock.vue @@ -0,0 +1,356 @@ + + + + + + + diff --git a/src/components/ConversationSettings/ConversationSettingsDialog.vue b/src/components/ConversationSettings/ConversationSettingsDialog.vue index 96123395aca..11fd4aefd01 100644 --- a/src/components/ConversationSettings/ConversationSettingsDialog.vue +++ b/src/components/ConversationSettings/ConversationSettingsDialog.vue @@ -49,6 +49,13 @@ + + + + + + + + + diff --git a/src/services/liveTranscriptionService.ts b/src/services/liveTranscriptionService.ts new file mode 100644 index 00000000000..7c2df7b0ea3 --- /dev/null +++ b/src/services/liveTranscriptionService.ts @@ -0,0 +1,58 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { + liveTranscriptionDisableResponse, + liveTranscriptionEnableResponse, + liveTranscriptionGetAvailableLanguagesResponse, + liveTranscriptionSetLanguageResponse, +} from '../types/index.ts' + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +/** + * Enable live transcription + * + * @param {string} token conversation token + */ +const enableLiveTranscription = async function(token: string): liveTranscriptionEnableResponse { + return axios.post(generateOcsUrl('apps/spreed/api/v1/live-transcription/{token}', { token })) +} + +/** + * Disable live transcription + * + * @param {string} token conversation token + */ +const disableLiveTranscription = async function(token: string): liveTranscriptionDisableResponse { + return axios.delete(generateOcsUrl('apps/spreed/api/v1/live-transcription/{token}', { token })) +} + +/** + * Get available languages for live transcriptions + */ +const getLiveTranscriptionLanguages = async function(): liveTranscriptionGetAvailableLanguagesResponse { + return axios.get(generateOcsUrl('apps/spreed/api/v1/live-transcription/languages')) +} + +/** + * Set language for live transcription + * + * @param {string} token conversation token + * @param {string} languageId the ID of the language + */ +const setLiveTranscriptionLanguage = async function(token: string, languageId: string): liveTranscriptionSetLanguageResponse { + return axios.post(generateOcsUrl('apps/spreed/api/v1/live-transcription/{token}/language', { token }), { + languageId, + }) +} + +export { + disableLiveTranscription, + enableLiveTranscription, + getLiveTranscriptionLanguages, + setLiveTranscriptionLanguage, +} diff --git a/src/store/conversationsStore.js b/src/store/conversationsStore.js index 9e53977f185..3747ba5a3d5 100644 --- a/src/store/conversationsStore.js +++ b/src/store/conversationsStore.js @@ -53,6 +53,7 @@ import { unarchiveConversation, unbindConversationFromObject, } from '../services/conversationsService.ts' +import { setLiveTranscriptionLanguage } from '../services/liveTranscriptionService.ts' import { clearConversationHistory, setConversationUnread, @@ -97,6 +98,7 @@ const DUMMY_CONVERSATION = { readOnly: CONVERSATION.STATE.READ_ONLY, listable: CONVERSATION.LISTABLE.NONE, mentions: CONVERSATION.MENTION_PERMISSIONS.EVERYONE, + liveTranscriptionLanguageId: '', hasCall: false, canStartCall: false, lobbyState: WEBINAR.LOBBY.NONE, @@ -267,6 +269,10 @@ const mutations = { state.conversations[token].mentionPermissions = mentionPermissions }, + setLiveTranscriptionLanguage(state, { token, languageId }) { + state.conversations[token].liveTranscriptionLanguageId = languageId + }, + setCallRecording(state, { token, callRecording }) { state.conversations[token].callRecording = callRecording }, @@ -1214,6 +1220,17 @@ const actions = { } }, + async setLiveTranscriptionLanguage(context, { token, languageId }) { + try { + await setLiveTranscriptionLanguage(token, languageId) + context.commit('setLiveTranscriptionLanguage', { token, languageId }) + } catch (error) { + console.error('Error while updating live transcription language: ', error) + + throw error + } + }, + async startCallRecording(context, { token, callRecording }) { try { await startCallRecording(token, callRecording) diff --git a/src/store/participantsStore.js b/src/store/participantsStore.js index 0d46e768495..5b0e76463c2 100644 --- a/src/store/participantsStore.js +++ b/src/store/participantsStore.js @@ -961,6 +961,15 @@ const actions = { return } + const callViewStore = useCallViewStore() + if (callViewStore.isLiveTranscriptionEnabled) { + // It is not awaited as it is not needed to guarantee that the + // transcription was disabled (the live_transcription app should + // detect it when the participant leaves) and thus it would + // unnecesarily delay leaving the call. + callViewStore.disableLiveTranscription(token) + } + await leaveCall(token, all) const updatedData = { diff --git a/src/stores/callView.ts b/src/stores/callView.ts index d9cd43e938c..38a51818988 100644 --- a/src/stores/callView.ts +++ b/src/stores/callView.ts @@ -8,6 +8,10 @@ import type { Conversation } from '../types/index.ts' import { defineStore } from 'pinia' import { CONVERSATION } from '../constants.ts' import BrowserStorage from '../services/BrowserStorage.js' +import { + disableLiveTranscription, + enableLiveTranscription, +} from '../services/liveTranscriptionService.ts' type State = { forceCallView: boolean @@ -20,6 +24,7 @@ type State = { presentationStarted: boolean selectedVideoPeerId: string | null callEndedTimeout: NodeJS.Timeout | number | undefined + isLiveTranscriptionEnabled: boolean } type CallViewModePayload = { @@ -41,6 +46,7 @@ export const useCallViewStore = defineStore('callView', { presentationStarted: false, selectedVideoPeerId: null, callEndedTimeout: undefined, + isLiveTranscriptionEnabled: false, }), getters: { @@ -164,5 +170,37 @@ export const useCallViewStore = defineStore('callView', { clearTimeout(this.callEndedTimeout) this.callEndedTimeout = undefined }, + + /** + * @throws error if live transcription could not be enabled. + */ + async enableLiveTranscription(token: string) { + try { + await enableLiveTranscription(token) + + this.isLiveTranscriptionEnabled = true + } catch (error) { + console.error(error) + + throw error + } + }, + + /** + * @throws error if live transcription could not be enabled. + */ + async disableLiveTranscription(token: string) { + try { + // Locally disable transcriptions even if they could not be + // disabled in the server. + this.isLiveTranscriptionEnabled = false + + await disableLiveTranscription(token) + } catch (error) { + console.error(error) + + throw error + } + }, }, }) diff --git a/src/stores/liveTranscription.ts b/src/stores/liveTranscription.ts new file mode 100644 index 00000000000..eba0844ef14 --- /dev/null +++ b/src/stores/liveTranscription.ts @@ -0,0 +1,57 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { + liveTranscriptionGetAvailableLanguagesResponse, + LiveTranscriptionLanguage, +} from '../types/index.ts' + +import { defineStore } from 'pinia' +import { getLiveTranscriptionLanguages } from '../services/liveTranscriptionService.ts' + +type LiveTranscriptionLanguageMap = { [key: string]: LiveTranscriptionLanguage } +type State = { + languages: LiveTranscriptionLanguageMap | liveTranscriptionGetAvailableLanguagesResponse | null +} +export const useLiveTranscriptionStore = defineStore('liveTranscription', { + state: (): State => ({ + languages: null, + }), + + actions: { + getLiveTranscriptionLanguages() { + if (!this.languages || this.languages instanceof Promise) { + return undefined + } + + return this.languages as LiveTranscriptionLanguageMap + }, + + /** + * Fetch the available languages for live transcriptions and save them + * in the store. + */ + async loadLiveTranscriptionLanguages() { + if (this.languages) { + if (this.languages instanceof Promise) { + await this.languages + } + + return + } + + this.languages = getLiveTranscriptionLanguages() + + try { + const response = await this.languages + this.languages = response.data.ocs.data + } catch (exception) { + this.languages = null + + throw exception + } + }, + }, +}) diff --git a/src/types/index.ts b/src/types/index.ts index d60fef1c703..dc870904ff1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -490,3 +490,11 @@ export type { createFileShareParams, createFileShareResponse, } from './core.ts' + +// Live transcription +export type LiveTranscriptionLanguage = components['schemas']['LiveTranscriptionLanguage'] + +export type liveTranscriptionDisableResponse = ApiResponse +export type liveTranscriptionEnableResponse = ApiResponse +export type liveTranscriptionGetAvailableLanguagesResponse = ApiResponse +export type liveTranscriptionSetLanguageResponse = ApiResponse diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index 4d77d785f6b..899755b29c4 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -236,6 +236,7 @@ export type components = { "max-duration": number; "blur-virtual-background": boolean; "end-to-end-encryption": boolean; + "live-transcription": boolean; }; chat: { /** Format: int64 */ diff --git a/src/types/openapi/openapi-backend-recording.ts b/src/types/openapi/openapi-backend-recording.ts index 25cfbd2e2d5..117aca838fa 100644 --- a/src/types/openapi/openapi-backend-recording.ts +++ b/src/types/openapi/openapi-backend-recording.ts @@ -70,6 +70,7 @@ export type components = { "max-duration": number; "blur-virtual-background": boolean; "end-to-end-encryption": boolean; + "live-transcription": boolean; }; chat: { /** Format: int64 */ diff --git a/src/types/openapi/openapi-backend-signaling.ts b/src/types/openapi/openapi-backend-signaling.ts index 8c9402bdcc5..2185cd34733 100644 --- a/src/types/openapi/openapi-backend-signaling.ts +++ b/src/types/openapi/openapi-backend-signaling.ts @@ -56,6 +56,7 @@ export type components = { "max-duration": number; "blur-virtual-background": boolean; "end-to-end-encryption": boolean; + "live-transcription": boolean; }; chat: { /** Format: int64 */ diff --git a/src/types/openapi/openapi-backend-sipbridge.ts b/src/types/openapi/openapi-backend-sipbridge.ts index 8549f471213..f451ca43619 100644 --- a/src/types/openapi/openapi-backend-sipbridge.ts +++ b/src/types/openapi/openapi-backend-sipbridge.ts @@ -171,6 +171,7 @@ export type components = { "max-duration": number; "blur-virtual-background": boolean; "end-to-end-encryption": boolean; + "live-transcription": boolean; }; chat: { /** Format: int64 */ @@ -404,6 +405,8 @@ export type components = { * @description Listable scope for the room (only available with `listable-rooms` capability) */ listable: number; + /** @description ID of the language to use for live transcriptions in the room, */ + liveTranscriptionLanguageId: string; /** * Format: int64 * @description Webinar lobby restriction (0-1), if the participant is a moderator they can always join the conversation (only available with `webinary-lobby` capability) (See [Webinar lobby states](https://nextcloud-talk.readthedocs.io/en/latest/constants#webinar-lobby-states)) diff --git a/src/types/openapi/openapi-bots.ts b/src/types/openapi/openapi-bots.ts index ed9382a1745..1c80ac17f36 100644 --- a/src/types/openapi/openapi-bots.ts +++ b/src/types/openapi/openapi-bots.ts @@ -74,6 +74,7 @@ export type components = { "max-duration": number; "blur-virtual-background": boolean; "end-to-end-encryption": boolean; + "live-transcription": boolean; }; chat: { /** Format: int64 */ diff --git a/src/types/openapi/openapi-federation.ts b/src/types/openapi/openapi-federation.ts index a4d9a09c3e7..20368aa2565 100644 --- a/src/types/openapi/openapi-federation.ts +++ b/src/types/openapi/openapi-federation.ts @@ -182,6 +182,7 @@ export type components = { "max-duration": number; "blur-virtual-background": boolean; "end-to-end-encryption": boolean; + "live-transcription": boolean; }; chat: { /** Format: int64 */ @@ -431,6 +432,8 @@ export type components = { * @description Listable scope for the room (only available with `listable-rooms` capability) */ listable: number; + /** @description ID of the language to use for live transcriptions in the room, */ + liveTranscriptionLanguageId: string; /** * Format: int64 * @description Webinar lobby restriction (0-1), if the participant is a moderator they can always join the conversation (only available with `webinary-lobby` capability) (See [Webinar lobby states](https://nextcloud-talk.readthedocs.io/en/latest/constants#webinar-lobby-states)) diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 0599f07e746..8c400872b7c 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -1593,6 +1593,58 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Enable the live transcription */ + post: operations["live_transcription-enable"]; + /** Disable the live transcription */ + delete: operations["live_transcription-disable"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/languages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get available languages for live transcriptions */ + get: operations["live_transcription-get-available-languages"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/{token}/language": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Set language for live transcriptions */ + post: operations["live_transcription-set-language"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/threads/recent": { parameters: { query?: never; @@ -2276,6 +2328,7 @@ export type components = { "max-duration": number; "blur-virtual-background": boolean; "end-to-end-encryption": boolean; + "live-transcription": boolean; }; chat: { /** Format: int64 */ @@ -2464,6 +2517,13 @@ export type components = { phones?: string[]; teams?: string[]; }; + LiveTranscriptionLanguage: { + name: string; + metadata: { + separator: string; + rtl: boolean; + }; + }; Matterbridge: { enabled: boolean; parts: components["schemas"]["MatterbridgeConfigFields"]; @@ -2705,6 +2765,8 @@ export type components = { * @description Listable scope for the room (only available with `listable-rooms` capability) */ listable: number; + /** @description ID of the language to use for live transcriptions in the room, */ + liveTranscriptionLanguageId: string; /** * Format: int64 * @description Webinar lobby restriction (0-1), if the participant is a moderator they can always join the conversation (only available with `webinary-lobby` capability) (See [Webinar lobby states](https://nextcloud-talk.readthedocs.io/en/latest/constants#webinar-lobby-states)) @@ -10260,6 +10322,223 @@ export interface operations { }; }; }; + "live_transcription-enable": { + 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?: never; + responses: { + /** @description Live transcription enabled successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description The participant is not in the call */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "app" | "in-call"; + }; + }; + }; + }; + }; + }; + }; + "live_transcription-disable": { + 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?: never; + responses: { + /** @description Live transcription stopped successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description The participant is not in the call */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "app" | "in-call"; + }; + }; + }; + }; + }; + }; + }; + "live_transcription-get-available-languages": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Available languages got successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + [key: string]: components["schemas"]["LiveTranscriptionLanguage"]; + }; + }; + }; + }; + }; + /** @description The external app "live_transcription" is not available */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "app"; + }; + }; + }; + }; + }; + }; + }; + "live_transcription-set-language": { + 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": { + /** @description the ID of the language to set */ + languageId: string; + }; + }; + }; + responses: { + /** @description Language set successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description The external app "live_transcription" is not available */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "app"; + }; + }; + }; + }; + }; + /** @description Participant is not a moderator */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "app"; + }; + }; + }; + }; + }; + }; + }; "thread-get-recent-active-threads": { parameters: { query?: { diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 8dd85ef0c7a..c0802b46708 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1593,6 +1593,58 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Enable the live transcription */ + post: operations["live_transcription-enable"]; + /** Disable the live transcription */ + delete: operations["live_transcription-disable"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/languages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get available languages for live transcriptions */ + get: operations["live_transcription-get-available-languages"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/live-transcription/{token}/language": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Set language for live transcriptions */ + post: operations["live_transcription-set-language"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/threads/recent": { parameters: { query?: never; @@ -1754,6 +1806,7 @@ export type components = { "max-duration": number; "blur-virtual-background": boolean; "end-to-end-encryption": boolean; + "live-transcription": boolean; }; chat: { /** Format: int64 */ @@ -1926,6 +1979,13 @@ export type components = { phones?: string[]; teams?: string[]; }; + LiveTranscriptionLanguage: { + name: string; + metadata: { + separator: string; + rtl: boolean; + }; + }; Matterbridge: { enabled: boolean; parts: components["schemas"]["MatterbridgeConfigFields"]; @@ -2167,6 +2227,8 @@ export type components = { * @description Listable scope for the room (only available with `listable-rooms` capability) */ listable: number; + /** @description ID of the language to use for live transcriptions in the room, */ + liveTranscriptionLanguageId: string; /** * Format: int64 * @description Webinar lobby restriction (0-1), if the participant is a moderator they can always join the conversation (only available with `webinary-lobby` capability) (See [Webinar lobby states](https://nextcloud-talk.readthedocs.io/en/latest/constants#webinar-lobby-states)) @@ -9722,6 +9784,223 @@ export interface operations { }; }; }; + "live_transcription-enable": { + 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?: never; + responses: { + /** @description Live transcription enabled successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description The participant is not in the call */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "app" | "in-call"; + }; + }; + }; + }; + }; + }; + }; + "live_transcription-disable": { + 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?: never; + responses: { + /** @description Live transcription stopped successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description The participant is not in the call */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "app" | "in-call"; + }; + }; + }; + }; + }; + }; + }; + "live_transcription-get-available-languages": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Available languages got successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + [key: string]: components["schemas"]["LiveTranscriptionLanguage"]; + }; + }; + }; + }; + }; + /** @description The external app "live_transcription" is not available */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "app"; + }; + }; + }; + }; + }; + }; + }; + "live_transcription-set-language": { + 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": { + /** @description the ID of the language to set */ + languageId: string; + }; + }; + }; + responses: { + /** @description Language set successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description The external app "live_transcription" is not available */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "app"; + }; + }; + }; + }; + }; + /** @description Participant is not a moderator */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "app"; + }; + }; + }; + }; + }; + }; + }; "thread-get-recent-active-threads": { parameters: { query?: { diff --git a/src/utils/webrtc/models/CallParticipantModel.js b/src/utils/webrtc/models/CallParticipantModel.js index a66465a9628..72cde0a32a3 100644 --- a/src/utils/webrtc/models/CallParticipantModel.js +++ b/src/utils/webrtc/models/CallParticipantModel.js @@ -71,6 +71,7 @@ export default function CallParticipantModel(options) { this._handleRaisedHandBound = this._handleRaisedHand.bind(this) this._handleRemoteVideoBlockedBound = this._handleRemoteVideoBlocked.bind(this) this._handleReactionBound = this._handleReaction.bind(this) + this._handleTranscriptBound = this._handleTranscript.bind(this) this._webRtc.on('peerStreamAdded', this._handlePeerStreamAddedBound) this._webRtc.on('peerStreamRemoved', this._handlePeerStreamRemovedBound) @@ -80,6 +81,7 @@ export default function CallParticipantModel(options) { this._webRtc.on('channelMessage', this._handleChannelMessageBound) this._webRtc.on('raisedHand', this._handleRaisedHandBound) this._webRtc.on('reaction', this._handleReactionBound) + this._webRtc.on('transcript', this._handleTranscriptBound) } CallParticipantModel.prototype = { @@ -412,6 +414,15 @@ CallParticipantModel.prototype = { this._trigger('reaction', [data.reaction]) }, + _handleTranscript(data) { + // A transcript could be sent even if there is no Peer object (yet). + if (this.get('peerId') !== data.id) { + return + } + + this._trigger('transcript', [data.message, data.languageId]) + }, + } EmitterMixin.apply(CallParticipantModel.prototype) diff --git a/src/utils/webrtc/simplewebrtc/simplewebrtc.js b/src/utils/webrtc/simplewebrtc/simplewebrtc.js index 2cbcc3034b0..0b84cc6d47e 100644 --- a/src/utils/webrtc/simplewebrtc/simplewebrtc.js +++ b/src/utils/webrtc/simplewebrtc/simplewebrtc.js @@ -128,6 +128,10 @@ export default function SimpleWebRTC(opts) { // "raisedHand" can be received from a participant without a Peer // object if that participant is not sending audio nor video. self.emit('raisedHand', { id: message.from, raised: message.payload }) + } else if (message.type === 'transcript') { + // "transcript" will be received from the transcription service, + // which will not have a Peer object. + self.emit('transcript', { id: message.speakerSessionId, message: message.message, languageId: message.langId }) } else if (peers.length) { peers.forEach(function(peer) { if (message.sid && !self.connection.hasFeature('mcu')) { diff --git a/tests/php/CapabilitiesTest.php b/tests/php/CapabilitiesTest.php index a9ef4340312..5f8eead8db6 100644 --- a/tests/php/CapabilitiesTest.php +++ b/tests/php/CapabilitiesTest.php @@ -13,6 +13,7 @@ use OCA\Talk\Config; use OCA\Talk\Participant; use OCA\Talk\Room; +use OCA\Talk\Service\LiveTranscriptionService; use OCP\App\IAppManager; use OCP\AppFramework\Services\IAppConfig; use OCP\Capabilities\IPublicCapability; @@ -39,6 +40,7 @@ class CapabilitiesTest extends TestCase { protected IAppManager&MockObject $appManager; protected ITranslationManager&MockObject $translationManager; protected ITaskProcessingManager&MockObject $taskProcessingManager; + protected LiveTranscriptionService&MockObject $liveTranscriptionService; protected ICacheFactory&MockObject $cacheFactory; protected ICache&MockObject $talkCache; @@ -52,6 +54,7 @@ public function setUp(): void { $this->appManager = $this->createMock(IAppManager::class); $this->translationManager = $this->createMock(ITranslationManager::class); $this->taskProcessingManager = $this->createMock(ITaskProcessingManager::class); + $this->liveTranscriptionService = $this->createMock(LiveTranscriptionService::class); $this->cacheFactory = $this->createMock(ICacheFactory::class); $this->talkCache = $this->createMock(ICache::class); @@ -79,6 +82,7 @@ protected function getCapabilities(): Capabilities { $this->appManager, $this->translationManager, $this->taskProcessingManager, + $this->liveTranscriptionService, $this->cacheFactory, ); } @@ -148,6 +152,7 @@ public function testGetCapabilitiesGuest(): void { 'max-duration' => 0, 'blur-virtual-background' => false, 'end-to-end-encryption' => false, + 'live-transcription' => false, 'predefined-backgrounds' => [ '1_office.jpg', '2_home.jpg', @@ -319,6 +324,7 @@ public function testGetCapabilitiesUserAllowed(bool $isNotAllowed, bool $canCrea 'max-duration' => 0, 'blur-virtual-background' => false, 'end-to-end-encryption' => false, + 'live-transcription' => false, 'predefined-backgrounds' => [ '1_office.jpg', '2_home.jpg', @@ -465,6 +471,31 @@ public function testConfigRecording(bool $enabled): void { $this->assertEquals($data['spreed']['config']['call']['recording'], $enabled); } + public static function dataTestConfigCallLiveTranscription(): array { + return [ + [Config::SIGNALING_EXTERNAL, true, true], + [Config::SIGNALING_EXTERNAL, false, false], + [Config::SIGNALING_INTERNAL, true, false], + [Config::SIGNALING_INTERNAL, false, false], + ]; + } + + #[DataProvider('dataTestConfigCallLiveTranscription')] + public function testConfigCallLiveTranscription(string $signalingMode, bool $liveTranscriptionAppEnabled, bool $expectedEnabled): void { + $capabilities = $this->getCapabilities(); + + $this->talkConfig->expects($this->any()) + ->method('getSignalingMode') + ->willReturn($signalingMode); + + $this->liveTranscriptionService->expects($this->any()) + ->method('isLiveTranscriptionAppEnabled') + ->willReturn($liveTranscriptionAppEnabled); + + $data = $capabilities->getCapabilities(); + $this->assertEquals($data['spreed']['config']['call']['live-transcription'], $expectedEnabled); + } + public function testCapabilitiesTranslations(): void { $capabilities = $this->getCapabilities(); diff --git a/tests/php/Service/RoomServiceTest.php b/tests/php/Service/RoomServiceTest.php index 1769b9e3134..1ed613eaef1 100644 --- a/tests/php/Service/RoomServiceTest.php +++ b/tests/php/Service/RoomServiceTest.php @@ -379,6 +379,7 @@ public function testVerifyPassword(): void { RecordingService::CONSENT_REQUIRED_NO, Room::HAS_FEDERATION_NONE, Room::MENTION_PERMISSIONS_EVERYONE, + '', ); $verificationResult = $service->verifyPassword($room, '1234');