feat: Add endpoint to enable and disable live transcriptions

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 <danxuliu@gmail.com>
This commit is contained in:
Daniel Calviño Sánchez 2025-07-28 14:01:12 +02:00
parent 2ce8e7d1b7
commit 6bfe44f1e3
8 changed files with 1003 additions and 2 deletions

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Exceptions\LiveTranscriptionAppNotEnabledException;
use OCA\Talk\Middleware\Attribute\RequireCallEnabled;
use OCA\Talk\Middleware\Attribute\RequireModeratorOrNoLobby;
use OCA\Talk\Middleware\Attribute\RequireParticipant;
use OCA\Talk\Participant;
use OCA\Talk\Service\LiveTranscriptionService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\IRequest;
class LiveTranscriptionController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
IRequest $request,
private LiveTranscriptionService $liveTranscriptionService,
) {
parent::__construct($appName, $request);
}
/**
* Enable the live transcription
*
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'app'|'in-call'}, array{}>
*
* 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<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'app'|'in-call'}, array{}>
*
* 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);
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Exceptions;
class LiveTranscriptionAppNotEnabledException extends \Exception {
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Exceptions;
use OCP\Http\Client\IResponse;
class LiveTranscriptionAppResponseException extends \Exception {
public function __construct(
string $message = '',
int $code = 0,
?\Throwable $previous = null,
protected IResponse $response,
) {
parent::__construct($message, $code, $previous);
}
public function getResponse(): IResponse {
return $this->response;
}
}

View file

@ -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;
}
}

View file

@ -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",

View file

@ -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",

View file

@ -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?: {

View file

@ -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?: {