Merge pull request #8720 from nextcloud/change-recording-status-when-notified-by-recording-server

Change recording status when notified by recording server
This commit is contained in:
Joas Schilling 2023-02-22 13:37:54 +01:00 committed by GitHub
commit 822e1898ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1351 additions and 75 deletions

View file

@ -34,6 +34,8 @@ return [
['name' => 'Recording#getWelcomeMessage', 'url' => '/api/{apiVersion}/recording/welcome/{serverId}', 'verb' => 'GET', 'requirements' => array_merge($requirements, [
'serverId' => '\d+',
])],
/** @see \OCA\Talk\Controller\RecordingController::backend() */
['name' => 'Recording#backend', 'url' => '/api/{apiVersion}/recording/backend', 'verb' => 'POST', 'requirements' => $requirements],
/** @see \OCA\Talk\Controller\RecordingController::start() */
['name' => 'Recording#start', 'url' => '/api/{apiVersion}/recording/{token}', 'verb' => 'POST', 'requirements' => $requirements],
/** @see \OCA\Talk\Controller\RecordingController::stop() */

View file

@ -111,6 +111,9 @@ title: Constants
* `0` - No recording
* `1` - Recording video
* `2` - Recording audio
* `3` - Starting video recording
* `4` - Starting audio recording
* `5` - Recording failed
## Chat

View file

@ -110,3 +110,88 @@
+ `400 Bad Request` Error: `system`: Internal system error
+ `403 Forbidden` When the user is not a moderator/owne
+ `404 Not Found` Room not found
## Recording server requests
* Required capability: `recording-v1`
* Method: `POST`
* Endpoint: `/recording/backend`
* Header:
| field | type | Description |
| ------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `TALK_RECORDING_RANDOM` | string | Random string that needs to be concatenated with request body to generate the checksum using the secret configured for the backend. |
| `TALK_RECORDING_CHECKSUM` | string | The checksum generated with `TALK_RECORDING_RANDOM`. |
* Data:
- Body as a JSON encoded string; format depends on the request type, see below.
* Response:
- Status code:
+ `200 OK`
+ `400 Bad Request`: When the body data does not match the expected format.
+ `403 Forbidden`: When the request validation failed.
### Started call recording
* Data format:
```json
{
"type": "started",
"started": {
"token": "the-token-of-the-room",
"status": "the-type-of-recording (see [Constants - Call recording status](constants.md#call-recording-status))",
"actor": {
"type": "the-type-of-the-actor",
"id": "the-id-of-the-actor",
},
},
}
```
* Response:
- (Additional) Status code:
+ `404 Not Found`: When the room is not found.
### Stopped call recording
* Data format:
```json
{
"type": "stopped",
"stopped": {
"token": "the-token-of-the-room",
"actor": {
"type": "the-type-of-the-actor",
"id": "the-id-of-the-actor",
},
},
}
```
- `actor` is optional
* Response:
- (Additional) Status code:
+ `404 Not Found`: When the room is not found.
### Failed call recording
* Data format:
```json
{
"type": "failed",
"failed": {
"token": "the-token-of-the-room",
},
}
```
* Response:
- (Additional) Status code:
+ `404 Not Found`: When the room is not found.

View file

@ -150,8 +150,8 @@ class Listener {
return false;
}
if ($room->getCallRecording() !== Room::RECORDING_NONE) {
$this->recordingService->stop($room);
if ($room->getCallRecording() !== Room::RECORDING_NONE && $room->getCallRecording() !== Room::RECORDING_FAILED) {
$this->recordingService->stop($room, $actor);
}
if ($actor instanceof Participant) {
$actorId = $actor->getAttendee()->getActorId();

View file

@ -544,6 +544,8 @@ class SystemMessage {
if ($currentUserIsActor) {
$parsedMessage = $this->l->t('You stopped the audio recording');
}
} elseif ($message === 'recording_failed') {
$parsedMessage = $this->l->t('The recording failed');
} elseif ($message === 'poll_voted') {
$parsedParameters['poll'] = $parameters['poll'];
$parsedParameters['poll']['id'] = (string) $parsedParameters['poll']['id'];

View file

@ -501,12 +501,23 @@ class Listener implements IEventListener {
}
public static function setCallRecording(ModifyRoomEvent $event): void {
$recordingHasStarted = in_array($event->getOldValue(), [Room::RECORDING_NONE, Room::RECORDING_VIDEO_STARTING, Room::RECORDING_AUDIO_STARTING, Room::RECORDING_FAILED])
&& in_array($event->getNewValue(), [Room::RECORDING_VIDEO, Room::RECORDING_AUDIO]);
$recordingHasStopped = in_array($event->getOldValue(), [Room::RECORDING_VIDEO, Room::RECORDING_AUDIO])
&& $event->getNewValue() === Room::RECORDING_NONE;
$recordingHasFailed = in_array($event->getOldValue(), [Room::RECORDING_VIDEO, Room::RECORDING_AUDIO])
&& $event->getNewValue() === Room::RECORDING_FAILED;
if (!$recordingHasStarted && !$recordingHasStopped && !$recordingHasFailed) {
return;
}
$prefix = self::getCallRecordingPrefix($event);
$suffix = self::getCallRecordingSuffix($event);
$systemMessage = $prefix . 'recording_' . $suffix;
$listener = Server::get(self::class);
$listener->sendSystemMessage($event->getRoom(), $systemMessage);
$listener->sendSystemMessage($event->getRoom(), $systemMessage, [], $event->getActor());
}
private static function getCallRecordingSuffix(ModifyRoomEvent $event): string {
@ -515,15 +526,20 @@ class Listener implements IEventListener {
Room::RECORDING_VIDEO,
Room::RECORDING_AUDIO,
];
$suffix = in_array($newStatus, $startStatus) ? 'started' : 'stopped';
return $suffix;
if (in_array($newStatus, $startStatus)) {
return 'started';
}
if ($newStatus === Room::RECORDING_FAILED) {
return 'failed';
}
return 'stopped';
}
private static function getCallRecordingPrefix(ModifyRoomEvent $event): string {
$newValue = $event->getNewValue();
$oldValue = $event->getOldValue();
$isAudioStatus = $newValue === Room::RECORDING_AUDIO
|| $oldValue === Room::RECORDING_AUDIO;
|| ($oldValue === Room::RECORDING_AUDIO && $newValue !== Room::RECORDING_FAILED);
return $isAudioStatus ? 'audio_' : '';
}

View file

@ -28,7 +28,13 @@ namespace OCA\Talk\Controller;
use InvalidArgumentException;
use GuzzleHttp\Exception\ConnectException;
use OCA\Talk\Config;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Manager;
use OCA\Talk\Room;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\RecordingService;
use OCA\Talk\Service\RoomService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\Http\Client\IClientService;
@ -42,7 +48,10 @@ class RecordingController extends AEnvironmentAwareController {
private ?string $userId,
private Config $talkConfig,
private IClientService $clientService,
private Manager $manager,
private ParticipantService $participantService,
private RecordingService $recordingService,
private RoomService $roomService,
private LoggerInterface $logger
) {
parent::__construct($appName, $request);
@ -109,13 +118,158 @@ class RecordingController extends AEnvironmentAwareController {
return hash_equals($hash, strtolower($checksum));
}
/**
* Return the body of the backend request. This can be overridden in
* tests.
*
* @return string
*/
protected function getInputStream(): string {
return file_get_contents('php://input');
}
/**
* Backend API to update recording status by backends.
*
* @PublicPage
* @BruteForceProtection(action=talkRecordingSecret)
*
* @return DataResponse
*/
public function backend(): DataResponse {
$json = $this->getInputStream();
if (!$this->validateBackendRequest($json)) {
$response = new DataResponse([
'type' => 'error',
'error' => [
'code' => 'invalid_request',
'message' => 'The request could not be authenticated.',
],
], Http::STATUS_FORBIDDEN);
$response->throttle();
return $response;
}
$message = json_decode($json, true);
switch ($message['type'] ?? '') {
case 'started':
return $this->backendStarted($message['started']);
case 'stopped':
return $this->backendStopped($message['stopped']);
case 'failed':
return $this->backendFailed($message['failed']);
default:
return new DataResponse([
'type' => 'error',
'error' => [
'code' => 'unknown_type',
'message' => 'The given type ' . json_encode($message) . ' is not supported.',
],
], Http::STATUS_BAD_REQUEST);
}
}
private function backendStarted(array $started): DataResponse {
$token = $started['token'];
$status = $started['status'];
$actor = $started['actor'];
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException $e) {
$this->logger->debug('Failed to get room {token}', [
'token' => $token,
'app' => 'spreed-recording',
]);
return new DataResponse([
'type' => 'error',
'error' => [
'code' => 'no_such_room',
'message' => 'Room not found.',
],
], Http::STATUS_NOT_FOUND);
}
try {
$participant = $this->participantService->getParticipantByActor($room, $actor['type'], $actor['id']);
} catch (ParticipantNotFoundException $e) {
$participant = null;
}
$this->roomService->setCallRecording($room, $status, $participant);
return new DataResponse();
}
private function backendStopped(array $stopped): DataResponse {
$token = $stopped['token'];
$actor = null;
if (array_key_exists('actor', $stopped)) {
$actor = $stopped['actor'];
}
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException $e) {
$this->logger->debug('Failed to get room {token}', [
'token' => $token,
'app' => 'spreed-recording',
]);
return new DataResponse([
'type' => 'error',
'error' => [
'code' => 'no_such_room',
'message' => 'Room not found.',
],
], Http::STATUS_NOT_FOUND);
}
try {
if ($actor === null) {
throw new ParticipantNotFoundException();
}
$participant = $this->participantService->getParticipantByActor($room, $actor['type'], $actor['id']);
} catch (ParticipantNotFoundException $e) {
$participant = null;
}
$this->roomService->setCallRecording($room, Room::RECORDING_NONE, $participant);
return new DataResponse();
}
private function backendFailed(array $failed): DataResponse {
$token = $failed['token'];
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException $e) {
$this->logger->debug('Failed to get room {token}', [
'token' => $token,
'app' => 'spreed-recording',
]);
return new DataResponse([
'type' => 'error',
'error' => [
'code' => 'no_such_room',
'message' => 'Room not found.',
],
], Http::STATUS_NOT_FOUND);
}
$this->roomService->setCallRecording($room, Room::RECORDING_FAILED);
return new DataResponse();
}
/**
* @NoAdminRequired
* @RequireLoggedInModeratorParticipant
*/
public function start(int $status): DataResponse {
try {
$this->recordingService->start($this->room, $status, $this->userId);
$this->recordingService->start($this->room, $status, $this->userId, $this->participant);
} catch (InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
@ -128,7 +282,7 @@ class RecordingController extends AEnvironmentAwareController {
*/
public function stop(): DataResponse {
try {
$this->recordingService->stop($this->room);
$this->recordingService->stop($this->room, $this->participant);
} catch (InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Daniel Calviño Sánchez <danxuliu@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Talk\Exceptions;
class RecordingNotFoundException extends \Exception {
}

View file

@ -24,10 +24,14 @@ declare(strict_types=1);
namespace OCA\Talk\Recording;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\ServerException;
use OCA\Talk\Config;
use OCA\Talk\Exceptions\RecordingNotFoundException;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCP\AppFramework\Http;
use OCP\Http\Client\IClientService;
use OCP\IURLGenerator;
use OCP\Security\ISecureRandom;
@ -136,13 +140,17 @@ class BackendNotifier {
$this->doRequest($url, $params);
}
public function start(Room $room, int $status, string $owner): void {
public function start(Room $room, int $status, string $owner, Participant $participant): void {
$start = microtime(true);
$this->backendRequest($room, [
'type' => 'start',
'start' => [
'status' => $status,
'owner' => $owner,
'actor' => [
'type' => $participant->getAttendee()->getActorType(),
'id' => $participant->getAttendee()->getActorId(),
],
],
]);
$duration = microtime(true) - $start;
@ -153,11 +161,28 @@ class BackendNotifier {
]);
}
public function stop(Room $room): void {
public function stop(Room $room, ?Participant $participant = null): void {
$parameters = [];
if ($participant !== null) {
$parameters['actor'] = [
'type' => $participant->getAttendee()->getActorType(),
'id' => $participant->getAttendee()->getActorId(),
];
}
$start = microtime(true);
$this->backendRequest($room, [
'type' => 'stop',
]);
try {
$this->backendRequest($room, [
'type' => 'stop',
'stop' => $parameters,
]);
} catch (ClientException $e) {
if ($e->getResponse()->getStatusCode() === Http::STATUS_NOT_FOUND) {
throw new RecordingNotFoundException();
}
throw $e;
}
$duration = microtime(true) - $start;
$this->logger->debug('Send stop message: {token} ({duration})', [
'token' => $room->getToken(),

View file

@ -59,6 +59,9 @@ class Room {
public const RECORDING_NONE = 0;
public const RECORDING_VIDEO = 1;
public const RECORDING_AUDIO = 2;
public const RECORDING_VIDEO_STARTING = 3;
public const RECORDING_AUDIO_STARTING = 4;
public const RECORDING_FAILED = 5;
/** @deprecated Use self::TYPE_UNKNOWN */
public const UNKNOWN_CALL = self::TYPE_UNKNOWN;

View file

@ -30,6 +30,7 @@ use OC\User\NoUserException;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Config;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\RecordingNotFoundException;
use OCA\Talk\Manager;
use OCA\Talk\Participant;
use OCA\Talk\Recording\BackendNotifier;
@ -70,31 +71,40 @@ class RecordingService {
) {
}
public function start(Room $room, int $status, string $owner): void {
public function start(Room $room, int $status, string $owner, Participant $participant): void {
$availableRecordingTypes = [Room::RECORDING_VIDEO, Room::RECORDING_AUDIO];
if (!in_array($status, $availableRecordingTypes)) {
throw new InvalidArgumentException('status');
}
if ($room->getCallRecording() !== Room::RECORDING_NONE) {
if ($room->getCallRecording() !== Room::RECORDING_NONE && $room->getCallRecording() !== Room::RECORDING_FAILED) {
throw new InvalidArgumentException('recording');
}
if (!$room->getActiveSince() instanceof \DateTimeInterface) {
throw new InvalidArgumentException('call');
}
if (!$this->config->isRecordingEnabled()) {
throw new InvalidArgumentException('config');
}
$this->backendNotifier->start($room, $status, $owner);
$this->backendNotifier->start($room, $status, $owner, $participant);
$this->roomService->setCallRecording($room, $status);
$startingStatus = $status == Room::RECORDING_VIDEO ? Room::RECORDING_VIDEO_STARTING : Room::RECORDING_AUDIO_STARTING;
$this->roomService->setCallRecording($room, $startingStatus);
}
public function stop(Room $room): void {
public function stop(Room $room, ?Participant $participant = null): void {
if ($room->getCallRecording() === Room::RECORDING_NONE) {
return;
}
$this->backendNotifier->stop($room);
$this->roomService->setCallRecording($room);
try {
$this->backendNotifier->stop($room, $participant);
} catch (RecordingNotFoundException $e) {
// If the recording to be stopped is not known to the recording
// server it will never notify that the recording was stopped, so
// the status needs to be explicitly changed here.
$this->roomService->setCallRecording($room, Room::RECORDING_FAILED);
}
}
public function store(Room $room, string $owner, array $file): void {

View file

@ -369,21 +369,23 @@ class RoomService {
/**
* @param Room $room
* @param integer $status 0 none|1 video|2 audio
* @param Participant|null $participant the Participant that changed the
* state, null for the current user
* @throws \InvalidArgumentException When the status is invalid, not Room::RECORDING_*
* @throws \InvalidArgumentException When trying to start
*/
public function setCallRecording(Room $room, int $status = Room::RECORDING_NONE): void {
public function setCallRecording(Room $room, int $status = Room::RECORDING_NONE, ?Participant $participant = null): void {
if (!$this->config->isRecordingEnabled() && $status !== Room::RECORDING_NONE) {
throw new InvalidArgumentException('config');
}
$availableRecordingStatus = [Room::RECORDING_NONE, Room::RECORDING_VIDEO, Room::RECORDING_AUDIO];
$availableRecordingStatus = [Room::RECORDING_NONE, Room::RECORDING_VIDEO, Room::RECORDING_AUDIO, Room::RECORDING_VIDEO_STARTING, Room::RECORDING_AUDIO_STARTING, Room::RECORDING_FAILED];
if (!in_array($status, $availableRecordingStatus)) {
throw new InvalidArgumentException('status');
}
$oldStatus = $room->getCallRecording();
$event = new ModifyRoomEvent($room, 'callRecording', $status, $oldStatus);
$event = new ModifyRoomEvent($room, 'callRecording', $status, $oldStatus, $participant);
$this->dispatcher->dispatch(Room::EVENT_BEFORE_SET_CALL_RECORDING, $event);
$update = $this->db->getQueryBuilder();

View file

@ -77,6 +77,7 @@
<file name="tests/stubs/oc_comments_comment.php" />
<file name="tests/stubs/oc_comments_manager.php" />
<file name="tests/stubs/oc_hooks_emitter.php" />
<file name="tests/stubs/GuzzleHttp_Exception_ClientException.php" />
<file name="tests/stubs/GuzzleHttp_Exception_ConnectException.php" />
<file name="tests/stubs/GuzzleHttp_Exception_ServerException.php" />
<file name="tests/stubs/Symfony_Component_EventDispatcher_GenericEvent.php" />

View file

@ -24,6 +24,10 @@
| `TALK_RECORDING_RANDOM` | string | Random string that needs to be concatenated with request body to generate the checksum using the secret configured for the backend. |
| `TALK_RECORDING_CHECKSUM` | string | The checksum generated with `TALK_RECORDING_RANDOM`. |
* Data:
- Body as a JSON encoded string; format depends on the request type, see below.
* Response:
- Status code:
+ `200 OK`
@ -33,7 +37,7 @@
### Start call recording
* Data format (JSON):
* Data format:
```json
{
@ -41,6 +45,10 @@
"start": {
"status": "the-type-of-recording (1 for audio and video, 2 for audio only)",
"owner": "the-user-to-upload-the-resulting-file-as",
"actor": {
"type": "the-type-of-the-actor",
"id": "the-id-of-the-actor",
},
}
}
```
@ -52,5 +60,17 @@
```json
{
"type": "stop",
"stop": {
"actor": {
"type": "the-type-of-the-actor",
"id": "the-id-of-the-actor",
},
},
}
```
- `actor` is optional
* Response:
- (Additional) Status code:
+ `404 Not Found`: When there is no recording for the token.

View file

@ -23,6 +23,7 @@ Module to send requests to the Nextcloud server.
import hashlib
import hmac
import json
import logging
import os
import ssl
@ -74,6 +75,102 @@ def doRequest(backend, request, retries=3):
logger.exception(f"Failed to send message to backend, giving up!")
raise
def backendRequest(backend, data):
"""
Sends the data to the backend on the endpoint to receive notifications from
the recording server.
The data is automatically wrapped in a request for the appropriate URL and
with the needed headers.
:param backend: the backend to send the data to.
:param data: the data to send.
"""
url = backend + '/ocs/v2.php/apps/spreed/api/v1/recording/backend'
data = json.dumps(data).encode()
random, checksum = getRandomAndChecksum(backend, data)
headers = {
'Content-Type': 'application/json',
'OCS-ApiRequest': 'true',
'Talk-Recording-Random': random,
'Talk-Recording-Checksum': checksum,
}
backendRequest = Request(url, data, headers)
doRequest(backend, backendRequest)
def started(backend, token, status, actorType, actorId):
"""
Notifies the backend that the recording was started.
:param backend: the backend of the conversation.
:param token: the token of the conversation.
:param actorType: the actor type of the Talk participant that stopped the
recording.
:param actorId: the actor id of the Talk participant that stopped the
recording.
"""
backendRequest(backend, {
'type': 'started',
'started': {
'token': token,
'status': status,
'actor': {
'type': actorType,
'id': actorId,
},
},
})
def stopped(backend, token, actorType, actorId):
"""
Notifies the backend that the recording was stopped.
:param backend: the backend of the conversation.
:param token: the token of the conversation.
:param actorType: the actor type of the Talk participant that started the
recording.
:param actorId: the actor id of the Talk participant that started the
recording.
"""
data = {
'type': 'stopped',
'stopped': {
'token': token,
},
}
if actorType != None and actorId != None:
data['stopped']['actor'] = {
'type': actorType,
'id': actorId,
}
backendRequest(backend, data)
def failed(backend, token):
"""
Notifies the backend that the recording failed.
:param backend: the backend of the conversation.
:param token: the token of the conversation.
"""
data = {
'type': 'failed',
'failed': {
'token': token,
},
}
backendRequest(backend, data)
def uploadRecording(backend, token, fileName, owner):
"""
Upload the recording specified by fileName.

View file

@ -28,7 +28,7 @@ import hmac
from threading import Lock, Thread
from flask import Flask, jsonify, request
from werkzeug.exceptions import BadRequest, Forbidden
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from nextcloud.talk import recording
from .Config import config
@ -37,6 +37,7 @@ from .Service import RECORDING_STATUS_AUDIO_AND_VIDEO, Service
app = Flask(__name__)
services = {}
servicesStopping = {}
servicesLock = Lock()
@app.route("/api/v1/welcome", methods=["GET"])
@ -119,12 +120,24 @@ def startRecording(backend, token, data):
if 'owner' not in data['start']:
raise BadRequest()
if 'actor' not in data['start']:
raise BadRequest()
if 'type' not in data['start']['actor']:
raise BadRequest()
if 'id' not in data['start']['actor']:
raise BadRequest()
status = RECORDING_STATUS_AUDIO_AND_VIDEO
if 'status' in data['start']:
status = data['start']['status']
owner = data['start']['owner']
actorType = data['start']['actor']['type']
actorId = data['start']['actor']['id']
service = None
with servicesLock:
if serviceId in services:
@ -137,12 +150,12 @@ def startRecording(backend, token, data):
app.logger.info(f"Start recording: {backend} {token}")
serviceStartThread = Thread(target=_startRecordingService, args=[service], daemon=True)
serviceStartThread = Thread(target=_startRecordingService, args=[service, actorType, actorId], daemon=True)
serviceStartThread.start()
return {}
def _startRecordingService(service):
def _startRecordingService(service, actorType, actorId):
"""
Helper function to start a recording service.
@ -154,7 +167,7 @@ def _startRecordingService(service):
serviceId = f'{service.backend}-{service.token}'
try:
service.start()
service.start(actorType, actorId)
except Exception as exception:
with servicesLock:
if serviceId not in services:
@ -171,23 +184,63 @@ def _startRecordingService(service):
def stopRecording(backend, token, data):
serviceId = f'{backend}-{token}'
if 'stop' not in data:
raise BadRequest()
actorType = None
actorId = None
if 'actor' in data['stop'] and 'type' in data['stop']['actor'] and 'id' in data['stop']['actor']:
actorType = data['stop']['actor']['type']
actorId = data['stop']['actor']['id']
service = None
with servicesLock:
if serviceId not in services and serviceId in servicesStopping:
app.logger.info(f"Trying to stop recording again: {backend} {token}")
return {}
if serviceId not in services:
app.logger.warning(f"Trying to stop unknown recording: {backend} {token}")
return {}
raise NotFound()
service = services[serviceId]
services.pop(serviceId)
servicesStopping[serviceId] = service
app.logger.info(f"Stop recording: {backend} {token}")
serviceStopThread = Thread(target=service.stop, daemon=True)
serviceStopThread = Thread(target=_stopRecordingService, args=[service, actorType, actorId], daemon=True)
serviceStopThread.start()
return {}
def _stopRecordingService(service, actorType, actorId):
"""
Helper function to stop a recording service.
The recording service will be removed from the list of services being
stopped once it is fully stopped.
:param service: the Service to stop.
"""
serviceId = f'{service.backend}-{service.token}'
try:
service.stop(actorType, actorId)
except Exception as exception:
app.logger.exception(f"Failed to stop recording: {service.backend} {service.token}")
finally:
with servicesLock:
if serviceId not in servicesStopping:
# This should never happen.
app.logger.error(f"Recording stopped when not in the list of stopping services: {service.backend} {service.token}")
return
servicesStopping.pop(serviceId)
# Despite this handler it seems that in some cases the geckodriver could have
# been killed already when it is executed, which unfortunately prevents a proper
# cleanup of the temporary files opened by the browser.

View file

@ -178,12 +178,16 @@ class Service:
def __del__(self):
self._stopHelpers()
def start(self):
def start(self, actorType, actorId):
"""
Starts the recording.
This method blocks until the recording ends.
:param actorType: the actor type of the Talk participant that started
the recording.
:param actorId: the actor id of the Talk participant that started the
recording.
:raise Exception: if the recording ends unexpectedly (including if it
could not be started).
"""
@ -222,6 +226,8 @@ class Service:
self._logger.debug("Joining call")
self._participant.joinCall(self.token)
BackendNotifier.started(self.backend, self.token, self.status, actorType, actorId)
extensionlessFileName = f'{fullDirectory}/recording-{datetime.now().strftime("%Y%m%d-%H%M%S")}'
recorderArgs = getRecorderArgs(self.status, self._display.new_display_var, audioSinkIndex, width, height, extensionlessFileName)
@ -244,20 +250,31 @@ class Service:
except Exception as exception:
self._stopHelpers()
try:
BackendNotifier.failed(self.backend, self.token)
except:
pass
raise
def stop(self):
def stop(self, actorType, actorId):
"""
Stops the recording and uploads it.
The recording is removed from the temporary directory once uploaded,
although it is kept if the upload fails.
:param actorType: the actor type of the Talk participant that stopped
the recording.
:param actorId: the actor id of the Talk participant that stopped the
recording.
:raise Exception: if the file could not be uploaded.
"""
self._stopHelpers()
BackendNotifier.stopped(self.backend, self.token, actorType, actorId)
if not self._fileName:
self._logger.error(f"Recording stopping before starting, nothing to upload")

View file

@ -137,7 +137,7 @@
</template>
</NcCheckboxRadioSwitch>
<NcNoteCard v-if="isRecording"
<NcNoteCard v-if="isStartingRecording || isRecording"
type="warning">
<p>{{ t('spreed', 'The call is being recorded.') }}</p>
</NcNoteCard>
@ -308,8 +308,14 @@ export default {
return this.conversation.hasCall || this.conversation.hasCallOverwrittenByChat
},
isStartingRecording() {
return this.conversation.callRecording === CALL.RECORDING.VIDEO_STARTING
|| this.conversation.callRecording === CALL.RECORDING.AUDIO_STARTING
},
isRecording() {
return this.conversation.callRecording !== CALL.RECORDING.OFF
return this.conversation.callRecording === CALL.RECORDING.VIDEO
|| this.conversation.callRecording === CALL.RECORDING.AUDIO
},
showSilentCallOption() {

View file

@ -150,8 +150,14 @@ export default {
return this.$store.getters.conversation(this.token) || this.$store.getters.dummyConversation
},
isStartingRecording() {
return this.conversation.callRecording === CALL.RECORDING.VIDEO_STARTING
|| this.conversation.callRecording === CALL.RECORDING.AUDIO_STARTING
},
isRecording() {
return this.conversation.callRecording !== CALL.RECORDING.OFF
return this.conversation.callRecording === CALL.RECORDING.VIDEO
|| this.conversation.callRecording === CALL.RECORDING.AUDIO
},
participantType() {
@ -299,7 +305,7 @@ export default {
const shouldShowDeviceCheckerScreen = (BrowserStorage.getItem('showDeviceChecker' + this.token) === null
|| BrowserStorage.getItem('showDeviceChecker' + this.token) === 'true') && !this.forceJoinCall
console.debug(shouldShowDeviceCheckerScreen)
if ((this.isRecording && !this.forceJoinCall) || shouldShowDeviceCheckerScreen) {
if (((this.isStartingRecording || this.isRecording) && !this.forceJoinCall) || shouldShowDeviceCheckerScreen) {
emit('talk:device-checker:show')
} else {
emit('talk:device-checker:hide')

View file

@ -27,19 +27,34 @@
:triggers="[]"
:container="container">
<template #trigger>
<NcButton :disabled="!isRecording || !isModerator"
<NcButton :disabled="(!isStartingRecording && !isRecording) || !isModerator"
:wide="true"
:class="{ 'call-time__not-recording': !isRecording }"
:class="{ 'call-time__not-recording': !isStartingRecording && !isRecording }"
:title="isStartingRecording ? t('spreed', 'Starting the recording') : t('spreed', 'Recording')"
type="tertiary"
@click="showPopover = true">
<template v-if="isRecording" #icon>
<template v-if="isStartingRecording" #icon>
<RecordCircle :size="20"
fill-color="var(--color-loading-light)" />
</template>
<template v-else-if="isRecording" #icon>
<RecordCircle :size="20"
fill-color="#e9322d" />
</template>
{{ formattedTime }}
</ncbutton>
</template>
<NcButton type="tertiary-no-background"
<NcButton v-if="isStartingRecording"
type="tertiary-no-background"
:wide="true"
@click="stopRecording">
<template #icon>
<NcLoadingIcon :size="20" />
</template>
{{ t('spreed', 'Cancel recording start') }}
</NcButton>
<NcButton v-else
type="tertiary-no-background"
:wide="true"
@click="stopRecording">
<template #icon>
@ -54,6 +69,7 @@
import RecordCircle from 'vue-material-design-icons/RecordCircle.vue'
import StopIcon from 'vue-material-design-icons/Stop.vue'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import NcPopover from '@nextcloud/vue/dist/Components/NcPopover.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import { CALL } from '../../constants.js'
@ -65,6 +81,7 @@ export default {
components: {
RecordCircle,
StopIcon,
NcLoadingIcon,
NcPopover,
NcButton,
},
@ -139,8 +156,14 @@ export default {
return this.$store.getters.conversation(this.token) || this.$store.getters.dummyConversation
},
isStartingRecording() {
return this.conversation.callRecording === CALL.RECORDING.VIDEO_STARTING
|| this.conversation.callRecording === CALL.RECORDING.AUDIO_STARTING
},
isRecording() {
return this.conversation.callRecording !== CALL.RECORDING.OFF
return this.conversation.callRecording === CALL.RECORDING.VIDEO
|| this.conversation.callRecording === CALL.RECORDING.AUDIO
},
},

View file

@ -117,7 +117,7 @@
</template>
<!-- Call recording -->
<template v-if="canModerateRecording">
<NcActionButton v-if="!isRecording && isInCall"
<NcActionButton v-if="!isRecording && !isStartingRecording && isInCall"
:close-after-click="true"
@click="startRecording">
<template #icon>
@ -125,6 +125,14 @@
</template>
{{ t('spreed', 'Start recording') }}
</NcActionButton>
<NcActionButton v-else-if="isStartingRecording && isInCall"
:close-after-click="true"
@click="stopRecording">
<template #icon>
<NcLoadingIcon :size="20" />
</template>
{{ t('spreed', 'Cancel recording start') }}
</NcActionButton>
<NcActionButton v-else-if="isRecording && isInCall"
:close-after-click="true"
@click="stopRecording">
@ -160,6 +168,7 @@ import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js'
import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import { emit } from '@nextcloud/event-bus'
import { generateUrl } from '@nextcloud/router'
import { getCapabilities } from '@nextcloud/capabilities'
@ -190,6 +199,7 @@ export default {
NcActionSeparator,
NcActionLink,
NcActionButton,
NcLoadingIcon,
PromotedView,
Cog,
DotsHorizontal,
@ -356,8 +366,14 @@ export default {
return this.canFullModerate && recordingEnabled
},
isStartingRecording() {
return this.conversation.callRecording === CALL.RECORDING.VIDEO_STARTING
|| this.conversation.callRecording === CALL.RECORDING.AUDIO_STARTING
},
isRecording() {
return this.conversation.callRecording !== CALL.RECORDING.OFF
return this.conversation.callRecording === CALL.RECORDING.VIDEO
|| this.conversation.callRecording === CALL.RECORDING.AUDIO
},
// True if current conversation is a breakout room and the brekour room has started

View file

@ -34,6 +34,9 @@ export const CALL = {
OFF: 0,
VIDEO: 1,
AUDIO: 2,
VIDEO_STARTING: 3,
AUDIO_STARTING: 4,
FAILED: 5,
},
}

View file

@ -23,6 +23,8 @@
// The purpose of this file is to wrap the logic shared by the different talk
// entry points
import { showError } from '@nextcloud/dialogs'
import { CALL, PARTICIPANT } from './constants.js'
import store from './store/index.js'
import { EventBus } from './services/EventBus.js'
@ -66,4 +68,18 @@ EventBus.$on('signaling-join-room', (payload) => {
EventBus.$on('signaling-recording-status-changed', (token, status) => {
store.dispatch('setConversationProperties', { token, properties: { callRecording: status } })
if (status !== CALL.RECORDING.FAILED) {
return
}
if (!store.getters.isInCall(store.getters.getToken())) {
return
}
const conversation = store.getters.conversation(store.getters.getToken())
if (conversation?.participantType === PARTICIPANT.TYPE.OWNER
|| conversation?.participantType === PARTICIPANT.TYPE.MODERATOR) {
showError(t('spreed', 'The recording failed. Please contact your administrator.'))
}
})

View file

@ -684,18 +684,27 @@ const actions = {
console.error(e)
}
showSuccess(t('spreed', 'Call recording started.'))
context.commit('setCallRecording', { token, callRecording })
const startingCallRecording = callRecording === CALL.RECORDING.VIDEO ? CALL.RECORDING.VIDEO_STARTING : CALL.RECORDING.AUDIO_STARTING
showSuccess(t('spreed', 'Call recording is starting.'))
context.commit('setCallRecording', { token, callRecording: startingCallRecording })
},
async stopCallRecording(context, { token }) {
const previousCallRecordingStatus = context.getters.conversation(token).callRecording
try {
await stopCallRecording(token)
} catch (e) {
console.error(e)
}
showInfo(t('spreed', 'Call recording stopped. You will be notified once the recording is available.'))
if (previousCallRecordingStatus === CALL.RECORDING.VIDEO_STARTING
|| previousCallRecordingStatus === CALL.RECORDING.VIDEO_STARTING) {
showInfo(t('spreed', 'Call recording stopped while starting.'))
} else {
showInfo(t('spreed', 'Call recording stopped. You will be notified once the recording is available.'))
}
context.commit('setCallRecording', { token, callRecording: CALL.RECORDING.OFF })
},
}

View file

@ -48,6 +48,7 @@ let tokensInSignaling = {}
/**
* @param {string} token The token of the conversation to get the signaling settings for
* @param {object} options The additional options for the request
*/
async function getSignalingSettings(token, options) {
// If getSignalingSettings is called again while a previous one was still
@ -79,6 +80,12 @@ async function getSignalingSettings(token, options) {
return settings
}
/**
* @param {string} token The token of the conversation to get the signaling settings for
* @param {string} random A string of at least 32 characters
* @param {string} checksum The SHA-256 HMAC of random with the secret of the
* recording server
*/
async function signalingGetSettingsForRecording(token, random, checksum) {
const options = {
headers: {

View file

@ -63,7 +63,7 @@ if (preg_match('/\/api\/v1\/welcome/', $_SERVER['REQUEST_URI'])) {
'data' => $data,
];
file_put_contents($receivedRequestsFile, json_encode($receivedRequests));
} elseif (preg_match('/requests/', $_SERVER['REQUEST_URI'])) {
} elseif (preg_match('/\/fake\/requests/', $_SERVER['REQUEST_URI'])) {
if (!file_exists($receivedRequestsFile)) {
return;
}
@ -74,6 +74,25 @@ if (preg_match('/\/api\/v1\/welcome/', $_SERVER['REQUEST_URI'])) {
unlink($receivedRequestsFile);
echo $requests;
} elseif (preg_match('/\/fake\/send-backend-request/', $_SERVER['REQUEST_URI'])) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $_SERVER['HTTP_BACKEND_URL']);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'OCS-APiRequest: true',
'Talk-Recording-Random: ' . $_SERVER['HTTP_TALK_RECORDING_RANDOM'],
'Talk-Recording-Checksum: ' . $_SERVER['HTTP_TALK_RECORDING_CHECKSUM'],
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, file_get_contents('php://input'));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
$responseCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
http_response_code($responseCode);
echo $result;
} else {
header('HTTP/1.0 404 Not Found');
}

View file

@ -153,6 +153,7 @@ class FeatureContext implements Context, SnippetAcceptingContext {
self::$tokenToIdentifier = [];
self::$sessionIdToUser = [
'cli' => 'cli',
'failed-to-get-session' => 'failed-to-get-session',
];
self::$userToSessionId = [];
self::$userToAttendeeId = [];
@ -3336,6 +3337,8 @@ class FeatureContext implements Context, SnippetAcceptingContext {
$options['form_params'] = $fd;
} elseif (is_array($body)) {
$options['form_params'] = $body;
} elseif (is_string($body)) {
$options['body'] = $body;
}
$options['headers'] = array_merge($headers, [

View file

@ -22,8 +22,13 @@ use Behat\Gherkin\Node\TableNode;
use GuzzleHttp\Client;
use PHPUnit\Framework\Assert;
// setAppConfig() method is expected to be available in the class that uses this
// trait.
// The following attributes and methods are expected to be available in the
// class that uses this trait:
// - baseUrl
// - assertStatusCode()
// - sendRequest()
// - sendRequestFullUrl()
// - setAppConfig()
trait RecordingTrait {
/** @var string */
private $recordingServerPid = '';
@ -40,7 +45,7 @@ trait RecordingTrait {
}
// "the secret" is hardcoded in the fake recording server.
$this->setAppConfig('spreed', new TableNode([['recording_servers', json_encode(['servers' => [['server' => 'http://127.0.0.1:9000']], 'secret' => 'the secret'])]]));
$this->setAppConfig('spreed', new TableNode([['recording_servers', json_encode(['servers' => [['server' => 'http://' . $this->recordingServerAddress]], 'secret' => 'the secret'])]]));
$this->recordingServerPid = exec('php -S ' . $this->recordingServerAddress . ' features/bootstrap/FakeRecordingServer.php >/dev/null & echo $!');
}
@ -63,6 +68,88 @@ trait RecordingTrait {
$this->recordingServerPid = '';
}
/**
* @When /^recording server sent started request for "(audio|video)" recording in room "([^"]*)" as "([^"]*)" with (\d+)(?: \((v1)\))?$/
*/
public function recordingServerSentStartedRequestForRecordingInRoomAsWith(string $recordingType, string $identifier, string $user, int $statusCode, string $apiVersion = 'v1') {
$recordingTypes = [
'video' => 1,
'audio' => 2,
];
$data = [
'type' => 'started',
'started' => [
'token' => FeatureContext::getTokenForIdentifier($identifier),
'status' => $recordingTypes[$recordingType],
'actor' => [
'type' => 'users',
'id' => $user,
],
],
];
$this->sendBackendRequestFromRecordingServer($data, $statusCode, $apiVersion);
}
/**
* @When /^recording server sent stopped request for recording in room "([^"]*)" with (\d+)(?: \((v1)\))?$/
*/
public function recordingServerSentStoppedRequestForRecordingInRoomWith(string $identifier, int $statusCode, string $apiVersion = 'v1') {
$this->recordingServerSentStoppedRequestForRecordingInRoomAsWith($identifier, null, $statusCode, $apiVersion);
}
/**
* @When /^recording server sent stopped request for recording in room "([^"]*)" as "([^"]*)" with (\d+)(?: \((v1)\))?$/
*/
public function recordingServerSentStoppedRequestForRecordingInRoomAsWith(string $identifier, ?string $user, int $statusCode, string $apiVersion = 'v1') {
$data = [
'type' => 'stopped',
'stopped' => [
'token' => FeatureContext::getTokenForIdentifier($identifier),
],
];
if ($user !== null) {
$data['stopped']['actor'] = [
'type' => 'users',
'id' => $user,
];
}
$this->sendBackendRequestFromRecordingServer($data, $statusCode, $apiVersion);
}
/**
* @When /^recording server sent failed request for recording in room "([^"]*)" with (\d+)(?: \((v1)\))?$/
*/
public function recordingServerSentFailedRequestForRecordingInRoomWith(string $identifier, int $statusCode, string $apiVersion = 'v1') {
$data = [
'type' => 'failed',
'failed' => [
'token' => FeatureContext::getTokenForIdentifier($identifier),
],
];
$this->sendBackendRequestFromRecordingServer($data, $statusCode, $apiVersion);
}
private function sendBackendRequestFromRecordingServer(array $data, int $statusCode, string $apiVersion = 'v1') {
$body = json_encode($data);
$random = md5((string) rand());
$checksum = hash_hmac('sha256', $random . $body, "the secret");
$headers = [
'Backend-Url' => $this->baseUrl . 'ocs/v2.php/apps/spreed/api/' . $apiVersion . '/recording/backend',
'Talk-Recording-Random' => $random,
'Talk-Recording-Checksum' => $checksum,
];
$this->sendRequestFullUrl('POST', 'http://' . $this->recordingServerAddress . '/fake/send-backend-request', $body, $headers);
$this->assertStatusCode($this->response, $statusCode);
}
/**
* @Then /^recording server received the following requests$/
*/
@ -91,7 +178,7 @@ trait RecordingTrait {
}
private function getRecordingServerReceivedRequests() {
$url = 'http://' . $this->recordingServerAddress . '/requests';
$url = 'http://' . $this->recordingServerAddress . '/fake/requests';
$client = new Client();
$response = $client->get($url);

View file

@ -13,10 +13,14 @@ Feature: callapi/recording
And user "participant1" joins room "room1" with 200 (v4)
And user "participant1" joins call "room1" with 200 (v4)
When user "participant1" starts "video" recording in room "room1" with 200 (v1)
Then recording server received the following requests
And recording server received the following requests
| token | data |
| room1 | {"type":"start","start":{"status":1,"owner":"participant1"}} |
And user "participant1" sees the following system messages in room "room1" with 200 (v1)
| room1 | {"type":"start","start":{"status":1,"owner":"participant1","actor":{"type":"users","id":"participant1"}}} |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 3 |
And recording server sent started request for "video" recording in room "room1" as "participant1" with 200
Then user "participant1" sees the following system messages in room "room1" with 200 (v1)
| room | actorType | actorId | actorDisplayName | systemMessage |
| room1 | users | participant1 | participant1-displayname | recording_started |
| room1 | users | participant1 | participant1-displayname | call_started |
@ -25,10 +29,14 @@ Feature: callapi/recording
| type | name | callRecording |
| 2 | room1 | 1 |
When user "participant1" stops recording in room "room1" with 200 (v1)
Then recording server received the following requests
And recording server received the following requests
| token | data |
| room1 | {"type":"stop"} |
And user "participant1" sees the following system messages in room "room1" with 200 (v1)
| room1 | {"type":"stop","stop":{"actor":{"type":"users","id":"participant1"}}} |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 1 |
And recording server sent stopped request for recording in room "room1" as "participant1" with 200
Then user "participant1" sees the following system messages in room "room1" with 200 (v1)
| room | actorType | actorId | actorDisplayName | systemMessage |
| room1 | users | participant1 | participant1-displayname | recording_stopped |
| room1 | users | participant1 | participant1-displayname | recording_started |
@ -48,10 +56,14 @@ Feature: callapi/recording
And user "participant1" joins room "room1" with 200 (v4)
And user "participant1" joins call "room1" with 200 (v4)
When user "participant1" starts "audio" recording in room "room1" with 200 (v1)
Then recording server received the following requests
And recording server received the following requests
| token | data |
| room1 | {"type":"start","start":{"status":2,"owner":"participant1"}} |
And user "participant1" sees the following system messages in room "room1" with 200 (v1)
| room1 | {"type":"start","start":{"status":2,"owner":"participant1","actor":{"type":"users","id":"participant1"}}} |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 4 |
And recording server sent started request for "audio" recording in room "room1" as "participant1" with 200
Then user "participant1" sees the following system messages in room "room1" with 200 (v1)
| room | actorType | actorId | actorDisplayName | systemMessage |
| room1 | users | participant1 | participant1-displayname | audio_recording_started |
| room1 | users | participant1 | participant1-displayname | call_started |
@ -60,10 +72,14 @@ Feature: callapi/recording
| type | name | callRecording |
| 2 | room1 | 2 |
When user "participant1" stops recording in room "room1" with 200 (v1)
Then recording server received the following requests
And recording server received the following requests
| token | data |
| room1 | {"type":"stop"} |
And user "participant1" sees the following system messages in room "room1" with 200 (v1)
| room1 | {"type":"stop","stop":{"actor":{"type":"users","id":"participant1"}}} |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 2 |
And recording server sent stopped request for recording in room "room1" as "participant1" with 200
Then user "participant1" sees the following system messages in room "room1" with 200 (v1)
| room | actorType | actorId | actorDisplayName | systemMessage |
| room1 | users | participant1 | participant1-displayname | audio_recording_stopped |
| room1 | users | participant1 | participant1-displayname | audio_recording_started |
@ -73,6 +89,178 @@ Feature: callapi/recording
| type | name | callRecording |
| 2 | room1 | 0 |
Scenario: Recording failed to start
Given recording server is started
And the following "spreed" app config is set
| signaling_dev | yes |
And user "participant1" creates room "room1" (v4)
| roomType | 2 |
| roomName | room1 |
And user "participant1" joins room "room1" with 200 (v4)
And user "participant1" joins call "room1" with 200 (v4)
And user "participant1" starts "video" recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"start","start":{"status":1,"owner":"participant1","actor":{"type":"users","id":"participant1"}}} |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 3 |
When recording server sent failed request for recording in room "room1" with 200
Then user "participant1" sees the following system messages in room "room1" with 200 (v1)
| room | actorType | actorId | actorDisplayName | systemMessage |
| room1 | users | participant1 | participant1-displayname | call_started |
| room1 | users | participant1 | participant1-displayname | conversation_created |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 5 |
Scenario: Video recording failed
Given recording server is started
And the following "spreed" app config is set
| signaling_dev | yes |
And user "participant1" creates room "room1" (v4)
| roomType | 2 |
| roomName | room1 |
And user "participant1" joins room "room1" with 200 (v4)
And user "participant1" joins call "room1" with 200 (v4)
And user "participant1" starts "video" recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"start","start":{"status":1,"owner":"participant1","actor":{"type":"users","id":"participant1"}}} |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 3 |
And recording server sent started request for "video" recording in room "room1" as "participant1" with 200
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 1 |
When recording server sent failed request for recording in room "room1" with 200
Then user "participant1" sees the following system messages in room "room1" with 200 (v1)
| room | actorType | actorId | actorDisplayName | systemMessage |
| room1 | guests | failed-to-get-session | | recording_failed |
| room1 | users | participant1 | participant1-displayname | recording_started |
| room1 | users | participant1 | participant1-displayname | call_started |
| room1 | users | participant1 | participant1-displayname | conversation_created |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 5 |
Scenario: Start and stop recording again after the previous one failed to start
Given recording server is started
And the following "spreed" app config is set
| signaling_dev | yes |
And user "participant1" creates room "room1" (v4)
| roomType | 2 |
| roomName | room1 |
And user "participant1" joins room "room1" with 200 (v4)
And user "participant1" joins call "room1" with 200 (v4)
And user "participant1" starts "video" recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"start","start":{"status":1,"owner":"participant1","actor":{"type":"users","id":"participant1"}}} |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 3 |
And recording server sent failed request for recording in room "room1" with 200
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 5 |
When user "participant1" starts "video" recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"start","start":{"status":1,"owner":"participant1","actor":{"type":"users","id":"participant1"}}} |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 3 |
And recording server sent started request for "video" recording in room "room1" as "participant1" with 200
Then user "participant1" sees the following system messages in room "room1" with 200 (v1)
| room | actorType | actorId | actorDisplayName | systemMessage |
| room1 | users | participant1 | participant1-displayname | recording_started |
| room1 | users | participant1 | participant1-displayname | call_started |
| room1 | users | participant1 | participant1-displayname | conversation_created |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 1 |
When user "participant1" stops recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"stop","stop":{"actor":{"type":"users","id":"participant1"}}} |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 1 |
And recording server sent stopped request for recording in room "room1" as "participant1" with 200
Then user "participant1" sees the following system messages in room "room1" with 200 (v1)
| room | actorType | actorId | actorDisplayName | systemMessage |
| room1 | users | participant1 | participant1-displayname | recording_stopped |
| room1 | users | participant1 | participant1-displayname | recording_started |
| room1 | users | participant1 | participant1-displayname | call_started |
| room1 | users | participant1 | participant1-displayname | conversation_created |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 0 |
Scenario: Start and stop recording again after the previous one failed
Given recording server is started
And the following "spreed" app config is set
| signaling_dev | yes |
And user "participant1" creates room "room1" (v4)
| roomType | 2 |
| roomName | room1 |
And user "participant1" joins room "room1" with 200 (v4)
And user "participant1" joins call "room1" with 200 (v4)
And user "participant1" starts "video" recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"start","start":{"status":1,"owner":"participant1","actor":{"type":"users","id":"participant1"}}} |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 3 |
And recording server sent started request for "video" recording in room "room1" as "participant1" with 200
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 1 |
And recording server sent failed request for recording in room "room1" with 200
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 5 |
When user "participant1" starts "video" recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"start","start":{"status":1,"owner":"participant1","actor":{"type":"users","id":"participant1"}}} |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 3 |
And recording server sent started request for "video" recording in room "room1" as "participant1" with 200
Then user "participant1" sees the following system messages in room "room1" with 200 (v1)
| room | actorType | actorId | actorDisplayName | systemMessage |
| room1 | users | participant1 | participant1-displayname | recording_started |
| room1 | guests | failed-to-get-session | | recording_failed |
| room1 | users | participant1 | participant1-displayname | recording_started |
| room1 | users | participant1 | participant1-displayname | call_started |
| room1 | users | participant1 | participant1-displayname | conversation_created |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 1 |
When user "participant1" stops recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"stop","stop":{"actor":{"type":"users","id":"participant1"}}} |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 1 |
And recording server sent stopped request for recording in room "room1" as "participant1" with 200
Then user "participant1" sees the following system messages in room "room1" with 200 (v1)
| room | actorType | actorId | actorDisplayName | systemMessage |
| room1 | users | participant1 | participant1-displayname | recording_stopped |
| room1 | users | participant1 | participant1-displayname | recording_started |
| room1 | guests | failed-to-get-session | | recording_failed |
| room1 | users | participant1 | participant1-displayname | recording_started |
| room1 | users | participant1 | participant1-displayname | call_started |
| room1 | users | participant1 | participant1-displayname | conversation_created |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 0 |
Scenario: Get error when start|stop recording and already did this
Given recording server is started
And the following "spreed" app config is set
@ -85,7 +273,8 @@ Feature: callapi/recording
When user "participant1" starts "audio" recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"start","start":{"status":2,"owner":"participant1"}} |
| room1 | {"type":"start","start":{"status":2,"owner":"participant1","actor":{"type":"users","id":"participant1"}}} |
And recording server sent started request for "audio" recording in room "room1" as "participant1" with 200
And user "participant1" starts "audio" recording in room "room1" with 400 (v1)
Then the response error matches with "recording"
And recording server received the following requests
@ -95,7 +284,12 @@ Feature: callapi/recording
When user "participant1" stops recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"stop"} |
| room1 | {"type":"stop","stop":{"actor":{"type":"users","id":"participant1"}}} |
And user "participant1" stops recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"stop","stop":{"actor":{"type":"users","id":"participant1"}}} |
And recording server sent stopped request for recording in room "room1" as "participant1" with 200
And user "participant1" stops recording in room "room1" with 200 (v1)
Then recording server received the following requests
And user "participant1" is participant of the following unordered rooms (v4)
@ -104,7 +298,8 @@ Feature: callapi/recording
When user "participant1" starts "video" recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"start","start":{"status":1,"owner":"participant1"}} |
| room1 | {"type":"start","start":{"status":1,"owner":"participant1","actor":{"type":"users","id":"participant1"}}} |
And recording server sent started request for "video" recording in room "room1" as "participant1" with 200
And user "participant1" starts "video" recording in room "room1" with 400 (v1)
Then the response error matches with "recording"
And recording server received the following requests
@ -114,7 +309,12 @@ Feature: callapi/recording
When user "participant1" stops recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"stop"} |
| room1 | {"type":"stop","stop":{"actor":{"type":"users","id":"participant1"}}} |
And user "participant1" stops recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"stop","stop":{"actor":{"type":"users","id":"participant1"}}} |
And recording server sent stopped request for recording in room "room1" as "participant1" with 200
And user "participant1" stops recording in room "room1" with 200 (v1)
Then recording server received the following requests
And user "participant1" is participant of the following unordered rooms (v4)
@ -220,14 +420,19 @@ Feature: callapi/recording
And user "participant1" starts "audio" recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"start","start":{"status":2,"owner":"participant1"}} |
| room1 | {"type":"start","start":{"status":2,"owner":"participant1","actor":{"type":"users","id":"participant1"}}} |
And recording server sent started request for "audio" recording in room "room1" as "participant1" with 200
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 2 |
When user "participant1" ends call "room1" with 200 (v4)
Then recording server received the following requests
| token | data |
| room1 | {"type":"stop"} |
| room1 | {"type":"stop","stop":{"actor":{"type":"users","id":"participant1"}}} |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 2 |
And recording server sent stopped request for recording in room "room1" as "participant1" with 200
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 0 |
@ -244,14 +449,19 @@ Feature: callapi/recording
And user "participant1" starts "audio" recording in room "room1" with 200 (v1)
And recording server received the following requests
| token | data |
| room1 | {"type":"start","start":{"status":2,"owner":"participant1"}} |
| room1 | {"type":"start","start":{"status":2,"owner":"participant1","actor":{"type":"users","id":"participant1"}}} |
And recording server sent started request for "audio" recording in room "room1" as "participant1" with 200
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 2 |
When user "participant1" leaves room "room1" with 200 (v4)
Then recording server received the following requests
| token | data |
| room1 | {"type":"stop"} |
| room1 | {"type":"stop","stop":[]} |
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 2 |
And recording server sent stopped request for recording in room "room1" with 200
And user "participant1" is participant of the following unordered rooms (v4)
| type | name | callRecording |
| 2 | room1 | 0 |

View file

@ -25,6 +25,7 @@ use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Chat\SystemMessage\Listener;
use OCA\Talk\Events\AddParticipantsEvent;
use OCA\Talk\Events\ModifyParticipantEvent;
use OCA\Talk\Events\ModifyRoomEvent;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Participant;
use OCA\Talk\Room;
@ -317,4 +318,291 @@ class ListenerTest extends TestCase {
$this->dispatch(Room::EVENT_AFTER_PARTICIPANT_TYPE_SET, $event);
}
public function callRecordingChangeProvider() {
return [
[
Room::RECORDING_VIDEO_STARTING,
Room::RECORDING_NONE,
null,
null,
null,
],
[
Room::RECORDING_VIDEO_STARTING,
Room::RECORDING_NONE,
Attendee::ACTOR_USERS,
'alice',
null,
],
[
Room::RECORDING_VIDEO,
Room::RECORDING_VIDEO_STARTING,
null,
null,
['message' => 'recording_started', 'parameters' => []],
],
[
Room::RECORDING_VIDEO,
Room::RECORDING_VIDEO_STARTING,
Attendee::ACTOR_USERS,
'alice',
['message' => 'recording_started', 'parameters' => []],
],
[
Room::RECORDING_VIDEO,
Room::RECORDING_NONE,
null,
null,
['message' => 'recording_started', 'parameters' => []],
],
[
Room::RECORDING_VIDEO,
Room::RECORDING_NONE,
Attendee::ACTOR_USERS,
'alice',
['message' => 'recording_started', 'parameters' => []],
],
[
Room::RECORDING_AUDIO_STARTING,
Room::RECORDING_NONE,
null,
null,
null,
],
[
Room::RECORDING_AUDIO_STARTING,
Room::RECORDING_NONE,
Attendee::ACTOR_USERS,
'alice',
null,
],
[
Room::RECORDING_AUDIO,
Room::RECORDING_AUDIO_STARTING,
null,
null,
['message' => 'audio_recording_started', 'parameters' => []],
],
[
Room::RECORDING_AUDIO,
Room::RECORDING_AUDIO_STARTING,
Attendee::ACTOR_USERS,
'alice',
['message' => 'audio_recording_started', 'parameters' => []],
],
[
Room::RECORDING_AUDIO,
Room::RECORDING_NONE,
null,
null,
['message' => 'audio_recording_started', 'parameters' => []],
],
[
Room::RECORDING_AUDIO,
Room::RECORDING_NONE,
Attendee::ACTOR_USERS,
'alice',
['message' => 'audio_recording_started', 'parameters' => []],
],
[
Room::RECORDING_NONE,
Room::RECORDING_VIDEO_STARTING,
null,
null,
null,
],
[
Room::RECORDING_NONE,
Room::RECORDING_VIDEO_STARTING,
Attendee::ACTOR_USERS,
'bob',
null,
],
[
Room::RECORDING_NONE,
Room::RECORDING_VIDEO,
null,
null,
['message' => 'recording_stopped', 'parameters' => []],
],
[
Room::RECORDING_NONE,
Room::RECORDING_VIDEO,
Attendee::ACTOR_USERS,
'bob',
['message' => 'recording_stopped', 'parameters' => []],
],
[
Room::RECORDING_NONE,
Room::RECORDING_AUDIO_STARTING,
null,
null,
null,
],
[
Room::RECORDING_NONE,
Room::RECORDING_AUDIO_STARTING,
Attendee::ACTOR_USERS,
'bob',
null,
],
[
Room::RECORDING_NONE,
Room::RECORDING_AUDIO,
null,
null,
['message' => 'audio_recording_stopped', 'parameters' => []],
],
[
Room::RECORDING_NONE,
Room::RECORDING_AUDIO,
Attendee::ACTOR_USERS,
'bob',
['message' => 'audio_recording_stopped', 'parameters' => []],
],
[
Room::RECORDING_FAILED,
Room::RECORDING_VIDEO_STARTING,
null,
null,
null,
],
[
Room::RECORDING_FAILED,
Room::RECORDING_AUDIO_STARTING,
null,
null,
null,
],
[
Room::RECORDING_FAILED,
Room::RECORDING_VIDEO,
null,
null,
['message' => 'recording_failed', 'parameters' => []],
],
[
Room::RECORDING_FAILED,
Room::RECORDING_AUDIO,
null,
null,
['message' => 'recording_failed', 'parameters' => []],
],
[
Room::RECORDING_VIDEO_STARTING,
Room::RECORDING_FAILED,
null,
null,
null,
],
[
Room::RECORDING_VIDEO_STARTING,
Room::RECORDING_FAILED,
Attendee::ACTOR_USERS,
'alice',
null,
],
[
Room::RECORDING_VIDEO,
Room::RECORDING_FAILED,
null,
null,
['message' => 'recording_started', 'parameters' => []],
],
[
Room::RECORDING_VIDEO,
Room::RECORDING_FAILED,
Attendee::ACTOR_USERS,
'alice',
['message' => 'recording_started', 'parameters' => []],
],
[
Room::RECORDING_AUDIO_STARTING,
Room::RECORDING_FAILED,
null,
null,
null,
],
[
Room::RECORDING_AUDIO_STARTING,
Room::RECORDING_FAILED,
Attendee::ACTOR_USERS,
'alice',
null,
],
[
Room::RECORDING_AUDIO,
Room::RECORDING_FAILED,
null,
null,
['message' => 'audio_recording_started', 'parameters' => []],
],
[
Room::RECORDING_AUDIO,
Room::RECORDING_FAILED,
Attendee::ACTOR_USERS,
'alice',
['message' => 'audio_recording_started', 'parameters' => []],
],
];
}
/**
* @dataProvider callRecordingChangeProvider
*
* @param int $newStatus
* @param int $oldStatus
* @param string|null $actorType
* @param string|null $actorId
* @param array $expectedMessage
*/
public function testAfterCallRecordingSet(int $newStatus, int $oldStatus, ?string $actorType, ?string $actorId, ?array $expectedMessage): void {
$this->mockLoggedInUser('logged_in_user');
$room = $this->createMock(Room::class);
$room->expects($this->any())
->method('getType')
->willReturn(Room::TYPE_PUBLIC);
if ($actorType !== null && $actorId !== null) {
$attendee = new Attendee();
$attendee->setActorType($actorType);
$attendee->setActorId($actorId);
$participant = $this->createMock(Participant::class);
$participant->method('getAttendee')->willReturn($attendee);
$expectedActorType = $actorType;
$expectedActorId = $actorId;
} else {
$participant = null;
$expectedActorType = Attendee::ACTOR_USERS;
$expectedActorId = 'logged_in_user';
}
$event = new ModifyRoomEvent($room, 'callRecording', $newStatus, $oldStatus, $participant);
if ($expectedMessage !== null) {
$this->chatManager->expects($this->once())
->method('addSystemMessage')
->with(
$room,
$expectedActorType,
$expectedActorId,
json_encode($expectedMessage),
$this->dummyTime,
false,
SELF::DUMMY_REFERENCE_ID,
null,
false
);
} else {
$this->chatManager->expects($this->never())
->method('addSystemMessage');
}
$this->dispatch(Room::EVENT_AFTER_SET_CALL_RECORDING, $event);
}
}

View file

@ -31,6 +31,7 @@ use OCA\Talk\Model\SessionMapper;
use OCA\Talk\Recording\BackendNotifier;
use OCA\Talk\Room;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\RoomService;
use OCA\Talk\TalkSession;
use OCP\App\IAppManager;
use OCP\AppFramework\Utility\ITimeFactory;
@ -39,6 +40,7 @@ use OCP\Http\Client\IClientService;
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Security\IHasher;
use OCP\Security\ISecureRandom;
@ -75,6 +77,8 @@ class BackendNotifierTest extends TestCase {
private $urlGenerator;
private ?\OCA\Talk\Tests\php\Recording\CustomBackendNotifier $backendNotifier = null;
/** @var ParticipantService|MockObject */
private $participantService;
private ?Manager $manager = null;
private ?string $recordingSecret = null;
@ -107,6 +111,8 @@ class BackendNotifierTest extends TestCase {
$this->recreateBackendNotifier();
$this->participantService = \OC::$server->get(ParticipantService::class);
$dbConnection = \OC::$server->getDatabaseConnection();
$this->manager = new Manager(
$dbConnection,
@ -115,7 +121,7 @@ class BackendNotifierTest extends TestCase {
\OC::$server->get(IAppManager::class),
\OC::$server->get(AttendeeMapper::class),
\OC::$server->get(SessionMapper::class),
$this->createMock(ParticipantService::class),
$this->participantService,
$this->secureRandom,
$this->createMock(IUserManager::class),
$groupManager,
@ -182,26 +188,78 @@ class BackendNotifierTest extends TestCase {
}
public function testStart() {
$room = $this->manager->createRoom(Room::TYPE_PUBLIC);
$userId = 'testUser';
$this->backendNotifier->start($room, Room::RECORDING_VIDEO, 'participant1');
/** @var IUser|MockObject $testUser */
$testUser = $this->createMock(IUser::class);
$testUser->expects($this->any())
->method('getUID')
->willReturn($userId);
$roomService = $this->createMock(RoomService::class);
$roomService->method('verifyPassword')
->willReturn(['result' => true, 'url' => '']);
$room = $this->manager->createRoom(Room::TYPE_PUBLIC);
$this->participantService->addUsers($room, [[
'actorType' => 'users',
'actorId' => $userId,
]]);
$participant = $this->participantService->joinRoom($roomService, $room, $testUser, '');
$this->backendNotifier->start($room, Room::RECORDING_VIDEO, 'participant1', $participant);
$this->assertMessageWasSent($room, [
'type' => 'start',
'start' => [
'status' => Room::RECORDING_VIDEO,
'owner' => 'participant1',
'actor' => [
'type' => 'users',
'id' => $userId,
],
],
]);
}
public function testStop() {
$userId = 'testUser';
/** @var IUser|MockObject $testUser */
$testUser = $this->createMock(IUser::class);
$testUser->expects($this->any())
->method('getUID')
->willReturn($userId);
$roomService = $this->createMock(RoomService::class);
$roomService->method('verifyPassword')
->willReturn(['result' => true, 'url' => '']);
$room = $this->manager->createRoom(Room::TYPE_PUBLIC);
$this->participantService->addUsers($room, [[
'actorType' => 'users',
'actorId' => $userId,
]]);
$participant = $this->participantService->joinRoom($roomService, $room, $testUser, '');
$this->backendNotifier->stop($room, $participant);
$this->assertMessageWasSent($room, [
'type' => 'stop',
'stop' => [
'actor' => [
'type' => 'users',
'id' => $userId,
],
],
]);
$this->backendNotifier->stop($room);
$this->assertMessageWasSent($room, [
'type' => 'stop',
'stop' => [
],
]);
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace GuzzleHttp\Exception;
class ClientException extends \RuntimeException {
public function getResponse() {
}
}