From 6bfe44f1e3290955bb77d738c540addd802f1f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Mon, 28 Jul 2025 14:01:12 +0200 Subject: [PATCH] feat: Add endpoint to enable and disable live transcriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint just forwards the request to the external app "live_transcription", but using a standard Talk endpoint makes possible to abstract that from the clients. Signed-off-by: Daniel Calviño Sánchez --- .../LiveTranscriptionController.php | 100 +++++++ ...iveTranscriptionAppNotEnabledException.php | 12 + .../LiveTranscriptionAppResponseException.php | 27 ++ lib/Service/LiveTranscriptionService.php | 142 +++++++++- openapi-full.json | 248 ++++++++++++++++++ openapi.json | 248 ++++++++++++++++++ src/types/openapi/openapi-full.ts | 114 ++++++++ src/types/openapi/openapi.ts | 114 ++++++++ 8 files changed, 1003 insertions(+), 2 deletions(-) create mode 100644 lib/Controller/LiveTranscriptionController.php create mode 100644 lib/Exceptions/LiveTranscriptionAppNotEnabledException.php create mode 100644 lib/Exceptions/LiveTranscriptionAppResponseException.php diff --git a/lib/Controller/LiveTranscriptionController.php b/lib/Controller/LiveTranscriptionController.php new file mode 100644 index 0000000000..34cf1eaf65 --- /dev/null +++ b/lib/Controller/LiveTranscriptionController.php @@ -0,0 +1,100 @@ +|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); + } +} diff --git a/lib/Exceptions/LiveTranscriptionAppNotEnabledException.php b/lib/Exceptions/LiveTranscriptionAppNotEnabledException.php new file mode 100644 index 0000000000..6f49f9d68d --- /dev/null +++ b/lib/Exceptions/LiveTranscriptionAppNotEnabledException.php @@ -0,0 +1,12 @@ +response; + } +} diff --git a/lib/Service/LiveTranscriptionService.php b/lib/Service/LiveTranscriptionService.php index 4215654d08..b9e518ffee 100644 --- a/lib/Service/LiveTranscriptionService.php +++ b/lib/Service/LiveTranscriptionService.php @@ -10,21 +10,32 @@ namespace OCA\Talk\Service; use OCA\AppAPI\PublicFunctions; use OCA\Talk\Exceptions\LiveTranscriptionAppAPIException; +use OCA\Talk\Exceptions\LiveTranscriptionAppNotEnabledException; +use OCA\Talk\Exceptions\LiveTranscriptionAppResponseException; +use OCA\Talk\Participant; +use OCA\Talk\Room; use OCP\App\IAppManager; +use OCP\IUserManager; use OCP\Server; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; +use Psr\Log\LoggerInterface; class LiveTranscriptionService { public function __construct( + private ?string $userId, private IAppManager $appManager, + private IUserManager $userManager, + protected LoggerInterface $logger, ) { } - public function isLiveTranscriptionAppEnabled(): bool { + public function isLiveTranscriptionAppEnabled(?object $appApiPublicFunctions = null): bool { try { - $appApiPublicFunctions = $this->getAppApiPublicFunctions(); + if ($appApiPublicFunctions === null) { + $appApiPublicFunctions = $this->getAppApiPublicFunctions(); + } } catch (LiveTranscriptionAppAPIException $e) { return false; } @@ -55,4 +66,131 @@ class LiveTranscriptionService { 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, + ]; + + $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. + */ + 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/openapi-full.json b/openapi-full.json index b143a0555e..a1bd1309ee 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -20876,6 +20876,254 @@ } } }, + "/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}/chat/{token}/threads/recent": { "get": { "operationId": "thread-get-recent-active-threads", diff --git a/openapi.json b/openapi.json index a66f434e8e..1cbcb6c0df 100644 --- a/openapi.json +++ b/openapi.json @@ -20781,6 +20781,254 @@ } } }, + "/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}/chat/{token}/threads/recent": { "get": { "operationId": "thread-get-recent-active-threads", diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 5526c34bcf..64481f4bfd 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -1593,6 +1593,24 @@ 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}/chat/{token}/threads/recent": { parameters: { query?: never; @@ -10261,6 +10279,102 @@ 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"; + }; + }; + }; + }; + }; + }; + }; "thread-get-recent-active-threads": { parameters: { query?: { diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 4dec915fee..a5d74aa7ae 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1593,6 +1593,24 @@ 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}/chat/{token}/threads/recent": { parameters: { query?: never; @@ -9723,6 +9741,102 @@ 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"; + }; + }; + }; + }; + }; + }; + }; "thread-get-recent-active-threads": { parameters: { query?: {