feat: Add capability for live transcriptions in calls

Live transcriptions is an optional feature that is only available if the
external app "live_transcription" is available.

Signed-off-by: Daniel Calviño Sánchez <danxuliu@gmail.com>
This commit is contained in:
Daniel Calviño Sánchez 2025-07-28 13:44:07 +02:00
parent d8f1d929a0
commit 2ce8e7d1b7
24 changed files with 158 additions and 8 deletions

View file

@ -196,3 +196,4 @@
## 22
* `threads` - Whether the chat supports threads
* `config => call => live-transcription` - Whether live transcription is supported in calls

View file

@ -9,6 +9,7 @@ declare(strict_types=1);
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 @@ class Capabilities implements IPublicCapability {
protected IAppManager $appManager,
protected ITranslationManager $translationManager,
protected ITaskProcessingManager $taskProcessingManager,
protected LiveTranscriptionService $liveTranscriptionService,
ICacheFactory $cacheFactory,
) {
$this->talkCache = $cacheFactory->createLocal('talk::');
@ -249,6 +251,8 @@ class Capabilities implements IPublicCapability {
'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,

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 LiveTranscriptionAppAPIException extends \Exception {
}

View file

@ -493,6 +493,7 @@ namespace OCA\Talk;
* max-duration: int,
* blur-virtual-background: bool,
* end-to-end-encryption: bool,
* live-transcription: bool,
* },
* chat: array{
* max-length: int,

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\AppAPI\PublicFunctions;
use OCA\Talk\Exceptions\LiveTranscriptionAppAPIException;
use OCP\App\IAppManager;
use OCP\Server;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
class LiveTranscriptionService {
public function __construct(
private IAppManager $appManager,
) {
}
public function isLiveTranscriptionAppEnabled(): bool {
try {
$appApiPublicFunctions = $this->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;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -36,6 +36,7 @@
<referencedClass name="GuzzleHttp\Exception\ServerException" />
<referencedClass name="GuzzleHttp\Exception\ConnectException" />
<referencedClass name="OC" />
<referencedClass name="OCA\AppAPI\PublicFunctions" />
<referencedClass name="OCA\Circles\Api\v1\Circles" />
<referencedClass name="OCA\Circles\CirclesManager" />
<referencedClass name="OCA\Circles\Events\AddingCircleMemberEvent" />
@ -61,6 +62,7 @@
<referencedClass name="Doctrine\DBAL\Schema\Table" />
<referencedClass name="OC\DB\ConnectionAdapter" />
<referencedClass name="OC\User\NoUserException" />
<referencedClass name="OCA\AppAPI\PublicFunctions" />
<referencedClass name="OCA\Circles\CirclesManager" />
<referencedClass name="OCA\Circles\Model\Circle" />
<referencedClass name="OCA\Circles\Model\Member" />

View file

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

View file

@ -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 */

View file

@ -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 */

View file

@ -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 */

View file

@ -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 */

View file

@ -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 */

View file

@ -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 */

View file

@ -2276,6 +2276,7 @@ export type components = {
"max-duration": number;
"blur-virtual-background": boolean;
"end-to-end-encryption": boolean;
"live-transcription": boolean;
};
chat: {
/** Format: int64 */

View file

@ -1754,6 +1754,7 @@ export type components = {
"max-duration": number;
"blur-virtual-background": boolean;
"end-to-end-encryption": boolean;
"live-transcription": boolean;
};
chat: {
/** Format: int64 */

View file

@ -13,6 +13,7 @@ use OCA\Talk\Chat\CommentsManager;
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 @@ class CapabilitiesTest extends TestCase {
$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 @@ class CapabilitiesTest extends TestCase {
$this->appManager,
$this->translationManager,
$this->taskProcessingManager,
$this->liveTranscriptionService,
$this->cacheFactory,
);
}
@ -148,6 +152,7 @@ class CapabilitiesTest extends TestCase {
'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 @@ class CapabilitiesTest extends TestCase {
'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 @@ class CapabilitiesTest extends TestCase {
$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();