feat(conversation): Add new API endpoint that allows to provide all settings

Signed-off-by: Joas Schilling <coding@schilljs.com>
This commit is contained in:
Joas Schilling 2025-02-25 17:49:43 +01:00
parent 21f3a7475f
commit fd677f258a
No known key found for this signature in database
GPG key ID: F72FA5B49FFA96B0
18 changed files with 1115 additions and 256 deletions

View file

@ -178,3 +178,6 @@
* `call-end-to-end-encryption` - Signaling support of the server for the end-to-end encryption of calls
* `config => call => end-to-end-encryption` - Whether calls should be end-to-end encrypted (currently off by default, until all Talk mobile clients support it)
+ `edit-draft-poll` - Whether moderators can edit draft polls
## 21.1
* `conversation-creation-all` - Whether the conversation creation endpoint allows to specify all attributes of a conversation

View file

@ -114,6 +114,7 @@ class Capabilities implements IPublicCapability {
'call-notification-state-api',
'schedule-meeting',
'edit-draft-poll',
'conversation-creation-all',
];
public const CONDITIONAL_FEATURES = [

View file

@ -8,6 +8,7 @@ declare(strict_types=1);
namespace OCA\Talk\Controller;
use OCA\Circles\Model\Circle;
use OCA\Talk\Capabilities;
use OCA\Talk\Config;
use OCA\Talk\Events\AAttendeeRemovedEvent;
@ -20,6 +21,7 @@ use OCA\Talk\Exceptions\InvalidPasswordException;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\ParticipantProperty\PermissionsException;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Exceptions\RoomProperty\CreationException;
use OCA\Talk\Exceptions\RoomProperty\DefaultPermissionsException;
use OCA\Talk\Exceptions\RoomProperty\DescriptionException;
use OCA\Talk\Exceptions\RoomProperty\ListableException;
@ -55,6 +57,7 @@ use OCA\Talk\Room;
use OCA\Talk\Service\BanService;
use OCA\Talk\Service\BreakoutRoomService;
use OCA\Talk\Service\ChecksumVerificationService;
use OCA\Talk\Service\InvitationService;
use OCA\Talk\Service\NoteToSelfService;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\RecordingService;
@ -95,7 +98,9 @@ use Psr\Log\LoggerInterface;
/**
* @psalm-import-type TalkCapabilities from ResponseDefinitions
* @psalm-import-type TalkParticipant from ResponseDefinitions
* @psalm-import-type TalkInvitationList from ResponseDefinitions
* @psalm-import-type TalkRoom from ResponseDefinitions
* @psalm-import-type TalkRoomWithInvalidInvitations from ResponseDefinitions
*/
class RoomController extends AEnvironmentAwareOCSController {
protected array $commonReadMessages = [];
@ -112,6 +117,7 @@ class RoomController extends AEnvironmentAwareOCSController {
protected RoomService $roomService,
protected BreakoutRoomService $breakoutRoomService,
protected NoteToSelfService $noteToSelfService,
protected InvitationService $invitationService,
protected ParticipantService $participantService,
protected SessionService $sessionService,
protected GuestManager $guestManager,
@ -507,57 +513,180 @@ class RoomController extends AEnvironmentAwareOCSController {
/**
* Create a room with a user, a group or a circle
*
* With the `conversation-creation-all` capability a lot of new options where
* introduced.
* Before that only `$roomType`, `$roomName`, `$objectType` and `$objectId`
* were supported all the time, and `$password` with the
* `conversation-creation-password` capability
* In case the `$roomType` is {@see Room::TYPE_ONE_TO_ONE} only the `$invite`
* or `$participants` parameter is supported.
*
* @param int $roomType Type of the room
* @psalm-param Room::TYPE_* $roomType
* @param string $invite User, group, ID to invite
* @param string $roomName Name of the room
* @param 'groups'|'circles'|'' $source Source of the invite ID ('circles' to create a room with a circle, etc.)
* @param string $objectType Type of the object
* @param string $objectId ID of the object
* @param string $password The room password (only available with `conversation-creation-password` capability)
* @return DataResponse<Http::STATUS_OK|Http::STATUS_CREATED, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{error: 'invite'|'mode'|'object'|'password'|'permissions'|'room'|'type', message?: string}, array{}>
* @param string $invite User, group, ID to invite **Deprecated** Use the `$participants` array instead
* @param string $roomName Name of the room, unless the legacy mode providing `$invite` and `$source` is used, the name must no longer be empty with the `conversation-creation-all` capability (Ignored if `$roomType` is {@see Room::TYPE_ONE_TO_ONE})
* @param 'groups'|'circles'|'' $source Source of the invite ID ('circles' to create a room with a circle, etc.) **Deprecated** Use the `$participants` array instead
* @param string $objectType Type of the object (Ignored if `$roomType` is {@see Room::TYPE_ONE_TO_ONE})
* @param string $objectId ID of the object (Ignored if `$roomType` is {@see Room::TYPE_ONE_TO_ONE})
* @param string $password The room password (only available with `conversation-creation-password` capability) (Ignored if `$roomType` is not {@see Room::TYPE_PUBLIC})
* @param 0|1 $readOnly Read only state of the conversation (Default writable) (only available with `conversation-creation-all` capability)
* @psalm-param Room::READ_* $readOnly
* @param 0|1|2 $listable Scope where the conversation is listable (Default not listable for anyone) (only available with `conversation-creation-all` capability)
* @psalm-param Room::LISTABLE_* $listable
* @param int $messageExpiration Seconds after which messages will disappear, 0 disables expiration (Default 0) (only available with `conversation-creation-all` capability)
* @psalm-param non-negative-int $messageExpiration
* @param 0|1 $lobbyState Lobby state of the conversation (Default lobby is disabled) (only available with `conversation-creation-all` capability)
* @psalm-param Webinary::LOBBY_* $lobbyState
* @param int|null $lobbyTimer Timer when the lobby will be removed (Default null, will not be disabled automatically) (only available with `conversation-creation-all` capability)
* @psalm-param non-negative-int|null $lobbyTimer
* @param 0|1|2 $sipEnabled Whether SIP dial-in shall be enabled (only available with `conversation-creation-all` capability)
* @psalm-param Webinary::SIP_* $sipEnabled
* @param int<0, 255> $permissions Default permissions for participants (only available with `conversation-creation-all` capability)
* @psalm-param int-mask-of<Attendee::PERMISSIONS_*> $permissions
* @param 0|1 $recordingConsent Whether participants need to agree to a recording before joining a call (only available with `conversation-creation-all` capability)
* @psalm-param RecordingService::CONSENT_REQUIRED_NO|RecordingService::CONSENT_REQUIRED_YES $recordingConsent
* @param 0|1 $mentionPermissions Who can mention at-all in the chat (only available with `conversation-creation-all` capability)
* @psalm-param Room::MENTION_PERMISSIONS_* $mentionPermissions
* @param string $description Description for the conversation (limited to 2.000 characters) (only available with `conversation-creation-all` capability)
* @param ?non-empty-string $emoji Emoji for the avatar of the conversation (only available with `conversation-creation-all` capability)
* @param ?non-empty-string $avatarColor Background color of the avatar (Only considered when an emoji was provided) (only available with `conversation-creation-all` capability)
* @param array<string, list<string>> $participants List of participants to add grouped by type (only available with `conversation-creation-all` capability)
* @psalm-param TalkInvitationList $participants
* @return DataResponse<Http::STATUS_OK|Http::STATUS_CREATED, TalkRoom, array{}>|DataResponse<Http::STATUS_ACCEPTED, TalkRoomWithInvalidInvitations, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{error: 'avatar'|'description'|'invite'|'listable'|'lobby'|'lobby-timer'|'mention-permissions'|'message-expiration'|'name'|'object'|'object-id'|'object-type'|'password'|'permissions'|'read-only'|'recording-consent'|'sip-enabled'|'type', message?: string}, array{}>
*
* 200: Room already existed
* 201: Room created successfully
* 202: Room created successfully but not all participants could be added
* 400: Room type invalid or missing or invalid password
* 403: Missing permissions to create room
* 404: User, group or other target to invite was not found
*/
#[NoAdminRequired]
public function createRoom(
int $roomType,
string $invite = '',
int $roomType = Room::TYPE_GROUP,
string $invite = '', /* @deprecated */
string $roomName = '',
string $source = '',
string $source = '', /* @deprecated */
string $objectType = '',
string $objectId = '',
string $password = '',
int $readOnly = Room::READ_WRITE,
int $listable = Room::LISTABLE_NONE,
int $messageExpiration = 0,
int $lobbyState = Webinary::LOBBY_NONE,
?int $lobbyTimer = null,
int $sipEnabled = Webinary::SIP_DISABLED,
int $permissions = Attendee::PERMISSIONS_DEFAULT,
int $recordingConsent = RecordingService::CONSENT_REQUIRED_NO,
int $mentionPermissions = Room::MENTION_PERMISSIONS_EVERYONE,
string $description = '',
?string $emoji = null,
?string $avatarColor = null,
array $participants = [],
): DataResponse {
if ($roomType !== Room::TYPE_ONE_TO_ONE) {
/** @var IUser $user */
$user = $this->userManager->get($this->userId);
if ($roomType === Room::TYPE_ONE_TO_ONE) {
if ($invite === ''
&& isset($participants['users'][0])
&& is_string($participants['users'][0])) {
$invite = $participants['users'][0];
}
if ($this->talkConfig->isNotAllowedToCreateConversations($user)) {
return new DataResponse(['error' => 'permissions'], Http::STATUS_FORBIDDEN);
return $this->createOneToOneRoom($invite);
}
/** @var IUser $user */
$user = $this->userManager->get($this->userId);
if ($this->talkConfig->isNotAllowedToCreateConversations($user)) {
return new DataResponse(['error' => 'permissions'], Http::STATUS_FORBIDDEN);
}
if ($invite !== '') {
// Legacy fallback for creating a conversation directly with a group or team
if ($source === 'circles') {
$sourceV2 = 'teams';
} else {
$sourceV2 = 'groups';
}
if (!isset($participants[$sourceV2])) {
$participants[$sourceV2] = [];
}
$participants[$sourceV2][] = $invite;
$participants[$sourceV2] = array_values(array_unique($participants[$sourceV2]));
}
if ($roomName === '') {
// Legacy fallback for creating a conversation without a name
$roomName = $this->roomService->prepareConversationName($invite ?: '---');
if ($source === 'circles') {
try {
$circle = $this->participantService->getCircle($invite, $this->userId);
$roomName = $this->roomService->prepareConversationName($circle->getName());
} catch (\Exception) {
}
} else {
$targetGroup = $this->groupManager->get($invite);
if ($targetGroup instanceof IGroup) {
$roomName = $this->roomService->prepareConversationName($targetGroup->getDisplayName());
}
}
}
switch ($roomType) {
case Room::TYPE_ONE_TO_ONE:
return $this->createOneToOneRoom($invite);
case Room::TYPE_GROUP:
if ($invite === '') {
return $this->createEmptyRoom($roomName, false, $objectType, $objectId);
}
if ($source === 'circles') {
return $this->createCircleRoom($invite);
}
return $this->createGroupRoom($invite);
case Room::TYPE_PUBLIC:
return $this->createEmptyRoom($roomName, true, $objectType, $objectId, $password);
if ($roomType !== Room::TYPE_PUBLIC) {
// Force empty password for non-public conversations
$password = '';
}
return new DataResponse(['error' => 'type'], Http::STATUS_BAD_REQUEST);
$invitationList = $this->invitationService->validateInvitations($participants, $user);
if ($invitationList->hasInvalidInvitations() && !$invitationList->hasValidInvitations()) {
// FIXME add the list of failed invitations?
return new DataResponse(['error' => 'invite'], Http::STATUS_NOT_FOUND);
}
if (!empty($invitationList->getEmails())) {
$roomType = Room::TYPE_PUBLIC;
}
try {
$room = $this->roomService->createConversation(
$roomType,
$roomName,
$user,
$objectType,
$objectId,
$password,
$readOnly,
$listable,
$messageExpiration,
$lobbyState,
$lobbyTimer,
$sipEnabled,
$permissions,
$recordingConsent,
$mentionPermissions,
$description,
$emoji,
$avatarColor,
allowInternalTypes: false,
);
} catch (CreationException $e) {
return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
} catch (PasswordException $e) {
return new DataResponse(['error' => 'password', 'message' => $e->getHint()], Http::STATUS_BAD_REQUEST);
}
if ($invitationList->hasValidInvitations()) {
$this->participantService->addInvitationList($room, $invitationList, $user);
}
if (!$invitationList->hasInvalidInvitations()) {
return new DataResponse($this->formatRoom($room, $this->participantService->getParticipant($room, $this->userId, false)), Http::STATUS_CREATED);
}
$data = $this->formatRoom($room, $this->participantService->getParticipant($room, $this->userId, false));
$data['invalidParticipants'] = $invitationList->getInvalidList();
return new DataResponse($data, Http::STATUS_ACCEPTED);
}
/**
@ -609,139 +738,6 @@ class RoomController extends AEnvironmentAwareOCSController {
}
}
/**
* Initiates a group video call from the selected group
*
* @param string $targetGroupName
* @return DataResponse<Http::STATUS_CREATED, TalkRoom, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: 'invite'}, array{}>
*/
#[NoAdminRequired]
protected function createGroupRoom(string $targetGroupName): DataResponse {
$currentUser = $this->userManager->get($this->userId);
if (!$currentUser instanceof IUser) {
// Should never happen, basically an internal server error so we reuse another error
return new DataResponse(['error' => 'invite'], Http::STATUS_NOT_FOUND);
}
$targetGroup = $this->groupManager->get($targetGroupName);
if (!$targetGroup instanceof IGroup) {
return new DataResponse(['error' => 'invite'], Http::STATUS_NOT_FOUND);
}
// Create the room
$name = $this->roomService->prepareConversationName($targetGroup->getDisplayName());
$room = $this->roomService->createConversation(Room::TYPE_GROUP, $name, $currentUser);
$this->participantService->addGroup($room, $targetGroup);
return new DataResponse($this->formatRoom($room, $this->participantService->getParticipant($room, $currentUser->getUID(), false)), Http::STATUS_CREATED);
}
/**
* Initiates a group video call from the selected circle
*
* @param string $targetCircleId
* @return DataResponse<Http::STATUS_CREATED, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{error: 'invite'}, array{}>
*/
#[NoAdminRequired]
protected function createCircleRoom(string $targetCircleId): DataResponse {
if (!$this->appManager->isEnabledForUser('circles')) {
return new DataResponse(['error' => 'invite'], Http::STATUS_BAD_REQUEST);
}
$currentUser = $this->userManager->get($this->userId);
if (!$currentUser instanceof IUser) {
// Should never happen, basically an internal server error so we reuse another error
return new DataResponse(['error' => 'invite'], Http::STATUS_NOT_FOUND);
}
try {
$circle = $this->participantService->getCircle($targetCircleId, $this->userId);
} catch (\Exception $e) {
return new DataResponse(['error' => 'invite'], Http::STATUS_NOT_FOUND);
}
// Create the room
$name = $this->roomService->prepareConversationName($circle->getName());
$room = $this->roomService->createConversation(Room::TYPE_GROUP, $name, $currentUser);
$this->participantService->addCircle($room, $circle);
return new DataResponse($this->formatRoom($room, $this->participantService->getParticipant($room, $currentUser->getUID(), false)), Http::STATUS_CREATED);
}
/**
* @return DataResponse<Http::STATUS_CREATED, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{error: 'invite'|'mode'|'object'|'password'|'permissions'|'room', message?: string}, array{}>
*/
#[NoAdminRequired]
protected function createEmptyRoom(string $roomName, bool $public = true, string $objectType = '', string $objectId = '', string $password = ''): DataResponse {
$currentUser = $this->userManager->get($this->userId);
if (!$currentUser instanceof IUser) {
// Should never happen, basically an internal server error so we reuse another error
return new DataResponse(['error' => 'invite'], Http::STATUS_NOT_FOUND);
}
$roomType = $public ? Room::TYPE_PUBLIC : Room::TYPE_GROUP;
/** @var Room|null $parentRoom */
$parentRoom = null;
if ($objectType === BreakoutRoom::PARENT_OBJECT_TYPE) {
try {
$parentRoom = $this->manager->getRoomForUserByToken($objectId, $this->userId);
$parentRoomParticipant = $this->participantService->getParticipant($parentRoom, $this->userId);
if (!$parentRoomParticipant->hasModeratorPermissions()) {
return new DataResponse(['error' => 'permissions'], Http::STATUS_BAD_REQUEST);
}
if ($parentRoom->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
return new DataResponse(['error' => 'mode'], Http::STATUS_BAD_REQUEST);
}
// Overwriting the type with the parent type.
$roomType = $parentRoom->getType();
} catch (RoomNotFoundException $e) {
return new DataResponse(['error' => 'room'], Http::STATUS_BAD_REQUEST);
} catch (ParticipantNotFoundException $e) {
return new DataResponse(['error' => 'permissions'], Http::STATUS_BAD_REQUEST);
}
} elseif ($objectType === Room::OBJECT_TYPE_PHONE) {
// Ignoring any user input on this one
$objectId = $objectType;
} elseif ($objectType === Room::OBJECT_TYPE_EVENT) {
// Allow event rooms in future versions without breaking in older talk versions that the same calendar version supports
$objectType = '';
$objectId = '';
} elseif ($objectType !== '') {
return new DataResponse(['error' => 'object'], Http::STATUS_BAD_REQUEST);
}
// Create the room
try {
$room = $this->roomService->createConversation($roomType, $roomName, $currentUser, $objectType, $objectId, $password);
} catch (PasswordException $e) {
return new DataResponse(['error' => 'password', 'message' => $e->getHint()], Http::STATUS_BAD_REQUEST);
} catch (\InvalidArgumentException $e) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
$currentParticipant = $this->participantService->getParticipant($room, $currentUser->getUID(), false);
if ($objectType === BreakoutRoom::PARENT_OBJECT_TYPE) {
// Enforce the lobby state when breakout rooms are disabled
if ($parentRoom instanceof Room && $parentRoom->getBreakoutRoomStatus() === BreakoutRoom::STATUS_STOPPED) {
$this->roomService->setLobby($room, Webinary::LOBBY_NON_MODERATORS, null, false, false);
}
$participants = $this->participantService->getParticipantsForRoom($parentRoom);
$moderators = array_filter($participants, static function (Participant $participant) use ($currentParticipant) {
return $participant->hasModeratorPermissions()
&& $participant->getAttendee()->getId() !== $currentParticipant->getAttendee()->getId();
});
if (!empty($moderators)) {
$this->breakoutRoomService->addModeratorsToBreakoutRooms([$room], $moderators);
}
}
return new DataResponse($this->formatRoom($room, $currentParticipant), Http::STATUS_CREATED);
}
/**
* Add a room to the favorites
*
@ -2304,7 +2300,7 @@ class RoomController extends AEnvironmentAwareOCSController {
/**
* Update the lobby state for a room
*
* @param int $state New state
* @param 0|1 $state New state
* @psalm-param Webinary::LOBBY_* $state
* @param int|null $timer Timer when the lobby will be removed
* @psalm-param non-negative-int|null $timer

View file

@ -0,0 +1,44 @@
<?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\RoomProperty;
class CreationException extends \InvalidArgumentException {
public const REASON_AVATAR = 'avatar';
public const REASON_DESCRIPTION = 'description';
public const REASON_LISTABLE = 'listable';
public const REASON_LOBBY = 'lobby';
public const REASON_LOBBY_TIMER = 'lobby-timer';
public const REASON_MESSAGE_EXPIRATION = 'message-expiration';
public const REASON_MENTION_PERMISSIONS = 'mention-permissions';
public const REASON_NAME = 'name';
public const REASON_OBJECT = 'object';
public const REASON_OBJECT_ID = 'object-id';
public const REASON_OBJECT_TYPE = 'object-type';
public const REASON_PERMISSIONS = 'permissions';
public const REASON_READ_ONLY = 'read-only';
public const REASON_RECORDING_CONSENT = 'recording-consent';
public const REASON_SIP_ENABLED = 'sip-enabled';
public const REASON_TYPE = 'type';
/**
* @param self::REASON_* $reason
*/
public function __construct(
protected string $reason,
) {
parent::__construct($reason);
}
/**
* @return self::REASON_*
*/
public function getReason(): string {
return $this->reason;
}
}

View file

@ -1102,16 +1102,32 @@ class Manager {
return $room;
}
/**
* @param int $type
* @param string $name
* @param string $objectType
* @param string $objectId
* @param string $password
* @return Room
*/
public function createRoom(int $type, string $name = '', string $objectType = '', string $objectId = '', string $password = ''): Room {
public function createRoom(
int $type,
string $name = '',
string $objectType = '',
string $objectId = '',
string $password = '',
?int $readOnly = null,
?int $listable = null,
?int $messageExpiration = null,
?int $lobbyState = null,
?\DateTime $lobbyTimer = null,
?int $sipEnabled = null,
?int $permissions = null,
?int $recordingConsent = null,
?int $mentionPermissions = null,
?string $description = null,
): Room {
$token = $this->getNewToken();
$row = [
'name' => $name,
'type' => $type,
'token' => $token,
'object_type' => $objectType,
'object_id' => $objectId,
'password' => $password,
];
$insert = $this->db->getQueryBuilder();
$insert->insert('talk_rooms')
@ -1129,17 +1145,50 @@ class Manager {
->setValue('object_id', $insert->createNamedParameter($objectId));
}
if ($readOnly !== null) {
$insert->setValue('read_only', $insert->createNamedParameter($readOnly, IQueryBuilder::PARAM_INT));
$row['read_only'] = $readOnly;
}
if ($listable !== null) {
$insert->setValue('listable', $insert->createNamedParameter($listable, IQueryBuilder::PARAM_INT));
$row['listable'] = $listable;
}
if ($messageExpiration !== null) {
$insert->setValue('message_expiration', $insert->createNamedParameter($messageExpiration, IQueryBuilder::PARAM_INT));
$row['message_expiration'] = $messageExpiration;
}
if ($lobbyState !== null) {
$insert->setValue('lobby_state', $insert->createNamedParameter($lobbyState, IQueryBuilder::PARAM_INT));
$row['lobby_state'] = $lobbyState;
if ($lobbyTimer !== null) {
$insert->setValue('lobby_timer', $insert->createNamedParameter($lobbyTimer, IQueryBuilder::PARAM_DATETIME_MUTABLE));
$row['lobby_timer'] = $lobbyTimer->format(\DATE_ATOM);
}
}
if ($sipEnabled !== null) {
$insert->setValue('sip_enabled', $insert->createNamedParameter($sipEnabled, IQueryBuilder::PARAM_INT));
$row['sip_enabled'] = $sipEnabled;
}
if ($permissions !== null) {
$insert->setValue('default_permissions', $insert->createNamedParameter($permissions, IQueryBuilder::PARAM_INT));
$row['default_permissions'] = $permissions;
}
if ($recordingConsent !== null) {
$insert->setValue('recording_consent', $insert->createNamedParameter($recordingConsent, IQueryBuilder::PARAM_INT));
$row['recording_consent'] = $recordingConsent;
}
if ($mentionPermissions !== null) {
$insert->setValue('mention_permissions', $insert->createNamedParameter($mentionPermissions, IQueryBuilder::PARAM_INT));
$row['mention_permissions'] = $mentionPermissions;
}
if ($description !== null) {
$insert->setValue('description', $insert->createNamedParameter($description));
$row['description'] = $description;
}
$insert->executeStatement();
$roomId = $insert->getLastInsertId();
$room = $this->createRoomObjectFromData([
'r_id' => $roomId,
'name' => $name,
'type' => $type,
'token' => $token,
'object_type' => $objectType,
'object_id' => $objectId,
'password' => $password
]);
$row['r_id'] = $insert->getLastInsertId();
$room = $this->createRoomObjectFromData($row);
$event = new RoomCreatedEvent($room);
$this->dispatcher->dispatchTyped($event);

View file

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Model;
use OCA\Circles\Model\Circle;
use OCP\Federation\ICloudId;
use OCP\IGroup;
use OCP\IUser;
class InvitationList {
/** @var array<string, IUser> */
protected array $validUsers = [];
/** @var list<string> */
protected array $invalidUsers = [];
/** @var array<string, ICloudId> */
protected array $validFederatedUsers = [];
/** @var list<string> */
protected array $invalidFederatedUsers = [];
/** @var array<string, IGroup> */
protected array $validGroups = [];
/** @var list<string> */
protected array $invalidGroups = [];
/** @var array<string, Circle> */
protected array $validTeams = [];
/** @var list<string> */
protected array $invalidTeams = [];
/** @var array<string, string> */
protected array $validEmails = [];
/** @var list<string> */
protected array $invalidEmails = [];
/** @var array<string, string> */
protected array $validPhoneNumbers = [];
/** @var list<string> */
protected array $invalidPhoneNumbers = [];
/**
* @param array<string, IUser> $valid
* @param list<string> $invalid
*/
public function setUserResults(array $valid, array $invalid): void {
$this->validUsers = $valid;
$this->invalidUsers = $invalid;
}
/**
* @param array<string, ICloudId> $valid
* @param list<string> $invalid
*/
public function setFederatedUserResults(array $valid, array $invalid): void {
$this->validFederatedUsers = $valid;
$this->invalidFederatedUsers = $invalid;
}
/**
* @param array<string, IGroup> $valid
* @param list<string> $invalid
*/
public function setGroupResults(array $valid, array $invalid): void {
$this->validGroups = $valid;
$this->invalidGroups = $invalid;
}
/**
* @param array<string, Circle> $valid
* @param list<string> $invalid
*/
public function setTeamResults(array $valid, array $invalid): void {
$this->validTeams = $valid;
$this->invalidTeams = $invalid;
}
/**
* @param array<string, string> $valid
* @param list<string> $invalid
*/
public function setEmailResults(array $valid, array $invalid): void {
$this->validEmails = $valid;
$this->invalidEmails = $invalid;
}
/**
* @param array<string, string> $valid
* @param list<string> $invalid
*/
public function setPhoneNumberResults(array $valid, array $invalid): void {
$this->validPhoneNumbers = $valid;
$this->invalidPhoneNumbers = $invalid;
}
/**
* @return array<string, IUser>
*/
public function getUsers(): array {
return $this->validUsers;
}
/**
* @return array<string, ICloudId>
*/
public function getFederatedUsers(): array {
return $this->validFederatedUsers;
}
/**
* @return array<string, IGroup>
*/
public function getGroup(): array {
return $this->validGroups;
}
/**
* @return array<string, Circle>
*/
public function getTeams(): array {
return $this->validTeams;
}
/**
* @return array<string, string>
*/
public function getEmails(): array {
return $this->validEmails;
}
/**
* @return array<string, string>
*/
public function getPhoneNumbers(): array {
return $this->validPhoneNumbers;
}
/**
* @return array<'users'|'federated_users'|'groups'|'emails'|'phones'|'teams', list<string>>
*/
public function getInvalidList(): array {
$response = [
'users' => $this->invalidUsers,
'federated_users' => $this->invalidFederatedUsers,
'groups' => $this->invalidGroups,
'teams' => $this->invalidTeams,
'emails' => $this->invalidEmails,
'phones' => $this->invalidPhoneNumbers,
];
return array_filter($response);
}
public function hasValidInvitations(): bool {
return !empty($this->validUsers)
|| !empty($this->validFederatedUsers)
|| !empty($this->validGroups)
|| !empty($this->validTeams)
|| !empty($this->validEmails)
|| !empty($this->validPhoneNumbers);
}
public function hasInvalidInvitations(): bool {
return !empty($this->invalidUsers)
|| !empty($this->invalidFederatedUsers)
|| !empty($this->invalidGroups)
|| !empty($this->invalidTeams)
|| !empty($this->invalidEmails)
|| !empty($this->invalidPhoneNumbers);
}
}

View file

@ -222,6 +222,15 @@ namespace OCA\Talk;
* timestamp: int,
* }
*
* @psalm-type TalkInvitationList = array{
* users?: list<string>,
* federated_users?: list<string>,
* groups?: list<string>,
* emails?: list<string>,
* phones?: list<string>,
* teams?: list<string>,
* }
*
* @psalm-type TalkRoom = array{
* actorId: string,
* invitedActorId?: string,
@ -284,6 +293,10 @@ namespace OCA\Talk;
* isArchived: bool,
* }
*
* @psalm-type TalkRoomWithInvalidInvitations = TalkRoom&array{
* invalidParticipants: TalkInvitationList,
* }
*
* @psalm-type TalkSignalingSession = array{
* actorId: string,
* actorType: string,

View file

@ -18,7 +18,6 @@ use OCP\Files\SimpleFS\InMemoryFile;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\IAvatarManager;
use OCP\IEmojiHelper;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
@ -36,7 +35,7 @@ class AvatarService {
private ISecureRandom $random,
private RoomService $roomService,
private IAvatarManager $avatarManager,
private IEmojiHelper $emojiHelper,
private EmojiService $emojiService,
) {
}
@ -73,7 +72,7 @@ class AvatarService {
throw new InvalidArgumentException($this->l->t('One-to-one rooms always need to show the other users avatar'));
}
if ($this->getFirstCombinedEmoji($emoji) !== $emoji) {
if ($this->emojiService->getFirstCombinedEmoji($emoji) !== $emoji) {
throw new InvalidArgumentException($this->l->t('Invalid emoji character'));
}
@ -207,12 +206,11 @@ class AvatarService {
}
}
}
if ($this->emojiHelper->doesPlatformSupportEmoji() && $this->emojiHelper->isValidSingleEmoji(mb_substr($room->getName(), 0, 1))) {
if ($this->emojiService->isValidSingleEmoji(mb_substr($room->getName(), 0, 1))) {
return new InMemoryFile(
$token,
$this->getEmojiAvatar(
$this->getFirstCombinedEmoji(
$room->getName()),
$this->emojiService->getFirstCombinedEmoji($room->getName()),
$darkTheme ? self::THEMING_DARK_BACKGROUND : self::THEMING_BRIGHT_BACKGROUND
)
);
@ -251,26 +249,6 @@ class AvatarService {
], $this->svgTemplate);
}
/**
* Get the first combined full emoji (including gender, skin tone, job, )
*
* @param string $roomName
* @param int $length
* @return string
*/
protected function getFirstCombinedEmoji(string $roomName, int $length = 0): string {
if (!$this->emojiHelper->doesPlatformSupportEmoji() || mb_strlen($roomName) === $length) {
return '';
}
$attempt = mb_substr($roomName, 0, $length + 1);
if ($this->emojiHelper->isValidSingleEmoji($attempt)) {
$longerAttempt = $this->getFirstCombinedEmoji($roomName, $length + 1);
return $longerAttempt ?: $attempt;
}
return '';
}
public function isCustomAvatar(Room $room): bool {
return $room->getAvatar() !== '';
}
@ -335,8 +313,8 @@ class AvatarService {
[$version] = explode('.', $avatarVersion);
return $version;
}
if ($this->emojiHelper->doesPlatformSupportEmoji() && $this->emojiHelper->isValidSingleEmoji(mb_substr($room->getName(), 0, 1))) {
return substr(md5($this->getEmojiAvatar($this->getFirstCombinedEmoji($room->getName()), self::THEMING_BRIGHT_BACKGROUND)), 0, 8);
if ($this->emojiService->isValidSingleEmoji(mb_substr($room->getName(), 0, 1))) {
return substr(md5($this->getEmojiAvatar($this->emojiService->getFirstCombinedEmoji($room->getName()), self::THEMING_BRIGHT_BACKGROUND)), 0, 8);
}
$avatarPath = $this->getAvatarPath($room);
return substr(md5($avatarPath), 0, 8);

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCP\IEmojiHelper;
class EmojiService {
public function __construct(
protected IEmojiHelper $emojiHelper,
) {
}
/**
* Get the first combined full emoji (including gender, skin tone, job, )
*
* @param string $roomName
* @param int $length
* @return string
*/
public function getFirstCombinedEmoji(string $roomName, int $length = 0): string {
if (!$this->emojiHelper->doesPlatformSupportEmoji() || mb_strlen($roomName) === $length) {
return '';
}
$attempt = mb_substr($roomName, 0, $length + 1);
if ($this->emojiHelper->isValidSingleEmoji($attempt)) {
$longerAttempt = $this->getFirstCombinedEmoji($roomName, $length + 1);
return $longerAttempt ?: $attempt;
}
return '';
}
public function isValidSingleEmoji(string $string): bool {
return $this->emojiHelper->doesPlatformSupportEmoji() && $this->emojiHelper->isValidSingleEmoji(mb_substr($string, 0, 1));
}
}

View file

@ -0,0 +1,199 @@
<?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\Talk\Config;
use OCA\Talk\Exceptions\FederationRestrictionException;
use OCA\Talk\Federation\FederationManager;
use OCA\Talk\MatterbridgeManager;
use OCA\Talk\Model\InvitationList;
use OCA\Talk\Room;
use OCP\App\IAppManager;
use OCP\Federation\ICloudIdManager;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IPhoneNumberUtil;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Mail\IMailer;
class InvitationService {
public function __construct(
protected IAppManager $appManager,
protected ICloudIdManager $cloudIdManager,
protected IGroupManager $groupManager,
protected IPhoneNumberUtil $phoneNumberUtil,
protected IUserManager $userManager,
protected FederationManager $federationManager,
protected ParticipantService $participantService,
protected IConfig $serverConfig,
protected Config $talkConfig,
protected IMailer $mailer,
) {
}
public function validateInvitations(array $participants, IUser $currentUser, ?Room $room = null): InvitationList {
$invitationList = new InvitationList();
if (!empty($participants['users'])) {
$this->validateUserInvitations($invitationList, $participants['users']);
}
if (!empty($participants['emails'])) {
$this->validateEmailInvitations($invitationList, $participants['emails']);
}
if (!empty($participants['groups'])) {
$this->validateGroupInvitations($invitationList, $participants['groups']);
}
if (!empty($participants['teams'])) {
$this->validateTeamInvitations($invitationList, $participants['teams'], $currentUser);
}
if (!empty($participants['federated_users'])) {
$this->validateFederatedUserInvitations($invitationList, $participants['federated_users'], $currentUser);
}
if (!empty($participants['phones'])) {
$this->validatePhoneInvitations($invitationList, $participants['phones'], $currentUser, $room);
}
return $invitationList;
}
/**
* @param list<string> $userIds
*/
protected function validateUserInvitations(InvitationList $invitationList, array $userIds): void {
$invalidUsers = $validUsers = [];
foreach ($userIds as $userId) {
if ($userId === MatterbridgeManager::BRIDGE_BOT_USERID) {
$invalidUsers[] = $userId;
continue;
}
$user = $this->userManager->get($userId);
if ($user instanceof IUser) {
$validUsers[$userId] = $user;
} else {
$invalidUsers[] = $userId;
}
}
$invitationList->setUserResults($validUsers, $invalidUsers);
}
/**
* @param list<string> $emails
*/
protected function validateEmailInvitations(InvitationList $invitationList, array $emails): void {
$invalidEmails = $validEmails = [];
foreach ($emails as $email) {
if ($this->mailer->validateMailAddress($email)) {
$validEmails[$email] = strtolower($email);
} else {
$invalidEmails[] = $email;
}
}
$invitationList->setEmailResults($validEmails, $invalidEmails);
}
/**
* @param list<string> $groupIds
*/
protected function validateGroupInvitations(InvitationList $invitationList, array $groupIds): void {
$invalidGroups = $validGroups = [];
foreach ($groupIds as $groupId) {
$group = $this->groupManager->get($groupId);
if ($group instanceof IGroup) {
$validGroups[$groupId] = $group;
} else {
$invalidGroups[] = $groupId;
}
}
$invitationList->setGroupResults($validGroups, $invalidGroups);
}
/**
* @param list<string> $teamIds
*/
protected function validateTeamInvitations(InvitationList $invitationList, array $teamIds, IUser $currentUser): void {
if (!$this->appManager->isEnabledForUser('circles')) {
$invitationList->setTeamResults([], $teamIds);
return;
}
$invalidTeams = $validTeams = [];
foreach ($teamIds as $teamId) {
try {
$team = $this->participantService->getCircle($teamId, $currentUser->getUID());
$validTeams[$teamId] = $team;
} catch (\Exception) {
$invalidTeams[] = $teamId;
}
}
$invitationList->setTeamResults($validTeams, $invalidTeams);
}
/**
* @param list<string> $cloudIds
*/
protected function validateFederatedUserInvitations(InvitationList $invitationList, array $cloudIds, IUser $currentUser): void {
if (!$this->talkConfig->isFederationEnabled()) {
$invitationList->setFederatedUserResults([], $cloudIds);
return;
}
$invalidCloudIds = $validCloudIds = [];
foreach ($cloudIds as $cloudIdString) {
try {
$cloudId = $this->cloudIdManager->resolveCloudId($cloudIdString);
$this->federationManager->isAllowedToInvite($currentUser, $cloudId);
$validCloudIds[$cloudIdString] = $cloudId;
} catch (\InvalidArgumentException|FederationRestrictionException) {
$invalidCloudIds[] = $cloudIdString;
}
}
$invitationList->setFederatedUserResults($validCloudIds, $invalidCloudIds);
}
/**
* @param list<string> $phoneNumbers
*/
protected function validatePhoneInvitations(InvitationList $invitationList, array $phoneNumbers, IUser $currentUser, ?Room $room): void {
if (!$this->talkConfig->isSIPConfigured() || !$this->talkConfig->canUserDialOutSIP($currentUser)) {
$invitationList->setPhoneNumberResults([], $phoneNumbers);
return;
}
if ($room instanceof Room
&& (preg_match(Room::SIP_INCOMPATIBLE_REGEX, $room->getToken())
|| !in_array($room->getType(), [Room::TYPE_GROUP, Room::TYPE_PUBLIC], true))) {
$invitationList->setPhoneNumberResults([], $phoneNumbers);
return;
}
$phoneRegion = $this->serverConfig->getSystemValueString('default_phone_region');
if ($phoneRegion === '') {
$phoneRegion = null;
}
$invalidPhoneNumbers = [];
$validPhoneNumbers = [];
foreach ($phoneNumbers as $phoneNumber) {
$formattedNumber = $this->phoneNumberUtil->convertToStandardFormat($phoneNumber, $phoneRegion);
if ($formattedNumber === null) {
$invalidPhoneNumbers[] = $phoneNumber;
} else {
$validPhoneNumbers[$phoneNumber] = $formattedNumber;
}
}
$invitationList->setPhoneNumberResults($validPhoneNumbers, $invalidPhoneNumbers);
}
}

View file

@ -46,10 +46,12 @@ use OCA\Talk\Exceptions\ParticipantProperty\PermissionsException;
use OCA\Talk\Exceptions\UnauthorizedException;
use OCA\Talk\Federation\BackendNotifier;
use OCA\Talk\Federation\FederationManager;
use OCA\Talk\GuestManager;
use OCA\Talk\Manager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\AttendeeMapper;
use OCA\Talk\Model\BreakoutRoom;
use OCA\Talk\Model\InvitationList;
use OCA\Talk\Model\SelectHelper;
use OCA\Talk\Model\Session;
use OCA\Talk\Model\SessionMapper;
@ -474,6 +476,65 @@ class ParticipantService {
return $participant;
}
public function addInvitationList(Room $room, InvitationList $invitationList, ?IUser $addedBy = null): void {
$participantsToAdd = [];
foreach ($invitationList->getUsers() as $user) {
$participantsToAdd[] = [
'actorType' => Attendee::ACTOR_USERS,
'actorId' => $user->getUID(),
'displayName' => $user->getDisplayName(),
];
}
foreach ($invitationList->getFederatedUsers() as $cloudId) {
$participantsToAdd[] = [
'actorType' => Attendee::ACTOR_FEDERATED_USERS,
'actorId' => $cloudId->getId(),
'displayName' => $cloudId->getDisplayId(),
];
}
foreach ($invitationList->getPhoneNumbers() as $phoneNumber) {
$participantsToAdd[] = [
'actorType' => Attendee::ACTOR_PHONES,
'actorId' => sha1($phoneNumber . '#' . $this->timeFactory->getTime()),
'displayName' => substr($phoneNumber, 0, -4) . '…', // FIXME Allow the UI to hand in a name (when selected from contacts?)
'phoneNumber' => $phoneNumber,
];
}
$existingParticipants = [];
if (!empty($participantsToAdd)) {
$attendees = $this->addUsers($room, $participantsToAdd, $addedBy);
$existingParticipants = array_map(static fn (Attendee $attendee): Participant => new Participant($room, $attendee, null), $attendees);
}
$emails = $invitationList->getEmails();
if (!empty($emails)) {
$guestManager = Server::get(GuestManager::class);
foreach ($emails as $email) {
$actorId = hash('sha256', $email);
try {
$this->getParticipantByActor($room, Attendee::ACTOR_EMAILS, $actorId);
} catch (ParticipantNotFoundException) {
$participant = $this->inviteEmailAddress($room, $actorId, $email);
try {
$guestManager->sendEmailInvitation($room, $participant);
} catch (\InvalidArgumentException) {
}
}
}
}
foreach ($invitationList->getGroup() as $group) {
$this->addGroup($room, $group, $existingParticipants);
}
foreach ($invitationList->getTeams() as $team) {
$this->addCircle($room, $team, $existingParticipants);
}
}
/**
* @param Room $room
* @param array $participants
@ -640,9 +701,9 @@ class ParticipantService {
/**
* @param Room $room
* @param IGroup $group
* @param Participant[] $existingParticipants
* @param Participant[] &$existingParticipants
*/
public function addGroup(Room $room, IGroup $group, array $existingParticipants = []): void {
public function addGroup(Room $room, IGroup $group, array &$existingParticipants = []): void {
$usersInGroup = $group->getUsers();
if (empty($existingParticipants)) {
@ -699,7 +760,10 @@ class ParticipantService {
$this->dispatcher->dispatchTyped($attendeeEvent);
}
$this->addUsers($room, $newParticipants, bansAlreadyChecked: true);
$attendees = $this->addUsers($room, $newParticipants, bansAlreadyChecked: true);
if (!empty($attendees)) {
$existingParticipants = array_merge(array_map(static fn (Attendee $attendee): Participant => new Participant($room, $attendee, null), $attendees), $existingParticipants);
}
}
/**
@ -759,9 +823,9 @@ class ParticipantService {
/**
* @param Room $room
* @param Circle $circle
* @param Participant[] $existingParticipants
* @param Participant[] &$existingParticipants
*/
public function addCircle(Room $room, Circle $circle, array $existingParticipants = []): void {
public function addCircle(Room $room, Circle $circle, array &$existingParticipants = []): void {
$membersInCircle = $circle->getInheritedMembers();
if (empty($existingParticipants)) {
@ -834,7 +898,10 @@ class ParticipantService {
$this->dispatcher->dispatchTyped($attendeeEvent);
}
$this->addUsers($room, $newParticipants, bansAlreadyChecked: true);
$attendees = $this->addUsers($room, $newParticipants, bansAlreadyChecked: true);
if (!empty($attendees)) {
$existingParticipants = array_merge(array_map(static fn (Attendee $attendee): Participant => new Participant($room, $attendee, null), $attendees), $existingParticipants);
}
}
public function inviteEmailAddress(Room $room, string $actorId, string $email, ?string $name = null): Participant {

View file

@ -31,6 +31,7 @@ use OCA\Talk\Exceptions\RoomProperty\AvatarException;
use OCA\Talk\Exceptions\RoomProperty\BreakoutRoomModeException;
use OCA\Talk\Exceptions\RoomProperty\BreakoutRoomStatusException;
use OCA\Talk\Exceptions\RoomProperty\CallRecordingException;
use OCA\Talk\Exceptions\RoomProperty\CreationException;
use OCA\Talk\Exceptions\RoomProperty\DefaultPermissionsException;
use OCA\Talk\Exceptions\RoomProperty\DescriptionException;
use OCA\Talk\Exceptions\RoomProperty\ListableException;
@ -62,6 +63,7 @@ use OCP\IUser;
use OCP\Log\Audit\CriticalActionPerformedEvent;
use OCP\Security\Events\ValidatePasswordPolicyEvent;
use OCP\Security\IHasher;
use OCP\Server;
use OCP\Share\IManager as IShareManager;
use Psr\Log\LoggerInterface;
@ -80,6 +82,7 @@ class RoomService {
protected IHasher $hasher,
protected IEventDispatcher $dispatcher,
protected IJobList $jobList,
protected EmojiService $emojiService,
protected LoggerInterface $logger,
protected IL10N $l10n,
) {
@ -130,47 +133,140 @@ class RoomService {
/**
* @return Room
* @throws InvalidArgumentException on too long or empty names
* @throws InvalidArgumentException unsupported type
* @throws InvalidArgumentException invalid object data
* @throws CreationException
* @throws PasswordException empty or invalid password
*/
public function createConversation(int $type, string $name, ?IUser $owner = null, string $objectType = '', string $objectId = '', string $password = ''): Room {
public function createConversation(
int $type,
string $name,
?IUser $owner = null,
string $objectType = '',
string $objectId = '',
string $password = '',
int $readOnly = Room::READ_WRITE,
int $listable = Room::LISTABLE_NONE,
int $messageExpiration = 0,
int $lobbyState = Webinary::LOBBY_NONE,
?int $lobbyTimer = null,
int $sipEnabled = Webinary::SIP_DISABLED,
int $permissions = Attendee::PERMISSIONS_DEFAULT,
int $recordingConsent = RecordingService::CONSENT_REQUIRED_NO,
int $mentionPermissions = Room::MENTION_PERMISSIONS_EVERYONE,
string $description = '',
?string $emoji = null,
?string $avatarColor = null,
bool $allowInternalTypes = true,
): Room {
$name = trim($name);
if ($name === '' || mb_strlen($name) > 255) {
throw new InvalidArgumentException('name');
throw new CreationException(CreationException::REASON_NAME);
}
if (!\in_array($type, [
$types = [
Room::TYPE_GROUP,
Room::TYPE_PUBLIC,
Room::TYPE_CHANGELOG,
Room::TYPE_NOTE_TO_SELF,
], true)) {
throw new InvalidArgumentException('type');
];
if ($allowInternalTypes) {
$types[] = Room::TYPE_CHANGELOG;
$types[] = Room::TYPE_NOTE_TO_SELF;
}
if (!\in_array($type, $types, true)) {
throw new CreationException(CreationException::REASON_TYPE);
}
$objectType = trim($objectType);
if (isset($objectType[64])) {
throw new InvalidArgumentException('object_type');
throw new CreationException(CreationException::REASON_OBJECT_TYPE);
}
$objectId = trim($objectId);
if (isset($objectId[64])) {
throw new InvalidArgumentException('object_id');
throw new CreationException(CreationException::REASON_OBJECT_ID);
}
$objectTypes = [
'',
Room::OBJECT_TYPE_PHONE,
Room::OBJECT_TYPE_EVENT,
];
if ($allowInternalTypes) {
$objectTypes[] = Room::OBJECT_TYPE_EMAIL;
$objectTypes[] = Room::OBJECT_TYPE_FILE;
$objectTypes[] = Room::OBJECT_TYPE_SAMPLE;
$objectTypes[] = Room::OBJECT_TYPE_VIDEO_VERIFICATION;
}
if (!in_array($objectType, $objectTypes, true)) {
throw new CreationException(CreationException::REASON_OBJECT_TYPE);
}
if (($objectType !== '' && $objectId === '') ||
($objectType === '' && $objectId !== '')) {
throw new InvalidArgumentException('object');
throw new CreationException(CreationException::REASON_OBJECT);
}
if ($type === Room::TYPE_PUBLIC && $password === '' && $this->config->isPasswordEnforced()) {
throw new PasswordException(PasswordException::REASON_VALUE, $this->l10n->t('Password needs to be set'));
}
if (!in_array($readOnly, [Room::READ_WRITE, Room::READ_ONLY], true)) {
throw new CreationException(CreationException::REASON_READ_ONLY);
}
if (!in_array($listable, [Room::LISTABLE_NONE, Room::LISTABLE_USERS, Room::LISTABLE_ALL], true)) {
throw new CreationException(CreationException::REASON_LISTABLE);
}
if ($messageExpiration < 0) {
throw new CreationException(CreationException::REASON_MESSAGE_EXPIRATION);
}
if (!in_array($lobbyState, [Webinary::LOBBY_NONE, Webinary::LOBBY_NON_MODERATORS], true)) {
throw new CreationException(CreationException::REASON_LOBBY);
}
$lobbyTimerDateTime = null;
if ($lobbyState !== Webinary::LOBBY_NONE && $lobbyTimer !== null) {
if ($lobbyTimer < 0) {
throw new CreationException(CreationException::REASON_LOBBY_TIMER);
}
$lobbyTimerDateTime = $this->timeFactory->getDateTime('@' . $lobbyTimer);
$lobbyTimerDateTime->setTimezone(new \DateTimeZone('UTC'));
}
if (!in_array($sipEnabled, [Webinary::SIP_DISABLED, Webinary::SIP_ENABLED, Webinary::SIP_ENABLED_NO_PIN], true)) {
throw new CreationException(CreationException::REASON_SIP_ENABLED);
}
if ($permissions < Attendee::PERMISSIONS_DEFAULT || $permissions > Attendee::PERMISSIONS_MAX_CUSTOM) {
throw new CreationException(CreationException::REASON_PERMISSIONS);
} elseif ($permissions !== Attendee::PERMISSIONS_DEFAULT) {
$permissions |= Attendee::PERMISSIONS_CUSTOM;
}
if (!in_array($recordingConsent, [RecordingService::CONSENT_REQUIRED_NO, RecordingService::CONSENT_REQUIRED_YES], true)) {
throw new CreationException(CreationException::REASON_RECORDING_CONSENT);
}
if (!in_array($mentionPermissions, [Room::MENTION_PERMISSIONS_EVERYONE, Room::MENTION_PERMISSIONS_MODERATORS], true)) {
throw new CreationException(CreationException::REASON_MENTION_PERMISSIONS);
}
if (mb_strlen($description) > Room::DESCRIPTION_MAXIMUM_LENGTH) {
throw new CreationException(CreationException::REASON_DESCRIPTION);
}
if ($emoji !== null) {
if ($this->emojiService->getFirstCombinedEmoji($emoji) !== $emoji) {
throw new CreationException(CreationException::REASON_AVATAR);
}
if ($avatarColor !== null && !preg_match('/^[a-fA-F0-9]{6}$/', $avatarColor)) {
throw new CreationException(CreationException::REASON_AVATAR);
}
}
if ($type !== Room::TYPE_PUBLIC || $password === '') {
$room = $this->manager->createRoom($type, $name, $objectType, $objectId);
$passwordHash = '';
} else {
$event = new ValidatePasswordPolicyEvent($password);
try {
@ -179,7 +275,29 @@ class RoomService {
throw new PasswordException(PasswordException::REASON_VALUE, $e->getHint());
}
$passwordHash = $this->hasher->hash($password);
$room = $this->manager->createRoom($type, $name, $objectType, $objectId, $passwordHash);
}
$room = $this->manager->createRoom(
$type,
$name,
$objectType,
$objectId,
$passwordHash,
$readOnly,
$listable,
$messageExpiration,
$lobbyState,
$lobbyTimerDateTime,
$sipEnabled,
$permissions,
$recordingConsent,
$mentionPermissions,
$description,
);
if ($emoji !== null) {
$avatarService = Server::get(AvatarService::class);
$avatarService->setAvatarFromEmoji($room, $emoji, $avatarColor);
}
if ($owner instanceof IUser) {
@ -1309,5 +1427,4 @@ class RoomService {
throw new TypeException(TypeException::REASON_BREAKOUT_ROOM);
}
}
}

View file

@ -62,6 +62,7 @@
<referencedClass name="OC\DB\ConnectionAdapter" />
<referencedClass name="OC\User\NoUserException" />
<referencedClass name="OCA\Circles\CirclesManager" />
<referencedClass name="OCA\Circles\Model\Circle" />
<referencedClass name="OCA\Circles\Model\Member" />
<referencedClass name="OCA\DAV\CardDAV\PhotoCache" />
<referencedClass name="OCA\FederatedFileSharing\AddressHandler" />

View file

@ -536,6 +536,15 @@ class FeatureContext implements Context, SnippetAcceptingContext {
if (isset($expectedRoom['lobbyState'])) {
$data['lobbyState'] = (int)$room['lobbyState'];
}
if (!empty($expectedRoom['lobbyTimer'])) {
$data['lobbyTimer'] = (int)$room['lobbyTimer'];
}
if (isset($expectedRoom['lobbyTimer'])) {
$data['lobbyTimer'] = (int)$room['lobbyTimer'];
if ($expectedRoom['lobbyTimer'] === 'GREATER_THAN_ZERO' && $room['lobbyTimer'] > 0) {
$data['lobbyTimer'] = 'GREATER_THAN_ZERO';
}
}
if (isset($expectedRoom['breakoutRoomMode'])) {
$data['breakoutRoomMode'] = (int)$room['breakoutRoomMode'];
}
@ -593,6 +602,9 @@ class FeatureContext implements Context, SnippetAcceptingContext {
if (isset($expectedRoom['defaultPermissions'])) {
$data['defaultPermissions'] = $this->mapPermissionsAPIOutput($room['defaultPermissions']);
}
if (isset($expectedRoom['mentionPermissions'])) {
$data['mentionPermissions'] = (int)$room['mentionPermissions'];
}
if (isset($expectedRoom['participants'])) {
throw new \Exception('participants key needs to be checked via participants endpoint');
}
@ -1158,6 +1170,14 @@ class FeatureContext implements Context, SnippetAcceptingContext {
}
}
if (isset($body['permissions'])) {
$body['permissions'] = $this->mapPermissionsTestInput($body['permissions']);
}
if (isset($body['lobbyTimer'])) {
if (preg_match('/^OFFSET\((\d+)\)$/', $body['lobbyTimer'], $matches)) {
$body['lobbyTimer'] = $matches[1] + time();
}
}
$this->setCurrentUser($user);
$this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/room', $body);

View file

@ -0,0 +1,115 @@
Feature: conversation-1/create
Background:
Given user "participant1" exists
Given user "participant2" exists
Scenario: Set password during creation
Given user "participant1" creates room "room1" (v4)
| roomType | 3 |
| roomName | room1 |
| password | P4$$w0rd |
Given user "participant1" creates room "room2" (v4)
| roomType | 2 |
| roomName | room2 |
| password | P4$$w0rd |
Then user "participant1" is participant of the following rooms (v4)
| id | type | participantType | hasPassword |
| room1 | 3 | 1 | 1 |
| room2 | 2 | 1 | |
Scenario: Read only during creation
Given user "participant1" creates room "room" (v4)
| roomType | 3 |
| roomName | room |
| readOnly | 1 |
Then user "participant1" is participant of the following rooms (v4)
| id | type | participantType | readOnly |
| room | 3 | 1 | 1 |
Scenario: Listable during creation
Given user "participant1" creates room "room" (v4)
| roomType | 3 |
| roomName | room |
| listable | 1 |
Then user "participant1" is participant of the following rooms (v4)
| id | type | participantType | listable |
| room | 3 | 1 | 1 |
Scenario: Set message expiration during creation
Given user "participant1" creates room "room" (v4)
| roomType | 3 |
| roomName | room |
| messageExpiration | 3600 |
Then user "participant1" is participant of the following rooms (v4)
| id | type | participantType | messageExpiration |
| room | 3 | 1 | 3600 |
Scenario: Set lobby during creation
Given user "participant1" creates room "room" (v4)
| roomType | 3 |
| roomName | room |
| lobbyState | 1 |
Then user "participant1" is participant of the following rooms (v4)
| id | type | participantType | lobbyState | lobbyTimer |
| room | 3 | 1 | 1 | 0 |
Scenario: Set lobby with timer during creation
Given user "participant1" creates room "room" (v4)
| roomType | 3 |
| roomName | room |
| lobbyState | 1 |
| lobbyTimer | OFFSET(3600) |
Then user "participant1" is participant of the following rooms (v4)
| id | type | participantType | lobbyState | lobbyTimer |
| room | 3 | 1 | 1 | GREATER_THAN_ZERO |
Scenario: Enable SIP during creation
Given the following "spreed" app config is set
| sip_bridge_dialin_info | +49-1234-567890 |
| sip_bridge_shared_secret | 1234567890abcdef |
| sip_bridge_groups | ["group1"] |
Given user "participant1" creates room "room" (v4)
| roomType | 3 |
| roomName | room |
| sipEnabled | 1 |
Then user "participant1" is participant of the following rooms (v4)
| id | type | participantType | sipEnabled |
| room | 3 | 1 | 1 |
Scenario: Set permissions during creation
Given user "participant1" creates room "room" (v4)
| roomType | 3 |
| roomName | room |
| permissions | AV |
Then user "participant1" is participant of the following rooms (v4)
| id | type | participantType | defaultPermissions |
| room | 3 | 1 | CAV |
Scenario: Set recording consent during creation
And the following "spreed" app config is set
| recording_consent | 2 |
Given user "participant1" creates room "room" (v4)
| roomType | 3 |
| roomName | room |
| recordingConsent | 1 |
Then user "participant1" is participant of the following rooms (v4)
| id | type | participantType | recordingConsent |
| room | 3 | 1 | 1 |
Scenario: Set mention permissions during creation
Given user "participant1" creates room "room" (v4)
| roomType | 3 |
| roomName | room |
| mentionPermissions | 1 |
Then user "participant1" is participant of the following rooms (v4)
| id | type | participantType | mentionPermissions |
| room | 3 | 1 | 1 |
Scenario: Set description during creation
Given user "participant1" creates room "room" (v4)
| roomType | 3 |
| roomName | room |
| description | Lorem ipsum |
Then user "participant1" is participant of the following rooms (v4)
| id | type | participantType | description |
| room | 3 | 1 | Lorem ipsum |

View file

@ -8,9 +8,9 @@ declare(strict_types=1);
namespace OCA\Talk\Tests\php\Service;
use OC\EmojiHelper;
use OCA\Talk\Room;
use OCA\Talk\Service\AvatarService;
use OCA\Talk\Service\EmojiService;
use OCA\Talk\Service\RoomService;
use OCP\Files\IAppData;
use OCP\IAvatarManager;
@ -31,7 +31,7 @@ class AvatarServiceTest extends TestCase {
protected ISecureRandom&MockObject $random;
protected RoomService&MockObject $roomService;
protected IAvatarManager&MockObject $avatarManager;
protected EmojiHelper $emojiHelper;
protected EmojiService $emojiService;
protected ?AvatarService $service = null;
public function setUp(): void {
@ -43,7 +43,7 @@ class AvatarServiceTest extends TestCase {
$this->random = $this->createMock(ISecureRandom::class);
$this->roomService = $this->createMock(RoomService::class);
$this->avatarManager = $this->createMock(IAvatarManager::class);
$this->emojiHelper = Server::get(EmojiHelper::class);
$this->emojiService = Server::get(EmojiService::class);
$this->service = new AvatarService(
$this->appData,
$this->l,
@ -51,7 +51,7 @@ class AvatarServiceTest extends TestCase {
$this->random,
$this->roomService,
$this->avatarManager,
$this->emojiHelper,
$this->emojiService,
);
}
@ -78,19 +78,4 @@ class AvatarServiceTest extends TestCase {
$this->assertEquals($expected, $actual);
}
}
public static function dataGetFirstCombinedEmoji(): array {
return [
['👋 Hello', '👋'],
['Only leading emojis 🚀', ''],
['👩🏽‍💻👩🏻‍💻👨🏿‍💻 Only one, but with all attributes', '👩🏽‍💻'],
];
}
/**
* @dataProvider dataGetFirstCombinedEmoji
*/
public function testGetFirstCombinedEmoji(string $roomName, string $avatarEmoji): void {
$this->assertSame($avatarEmoji, self::invokePrivate($this->service, 'getFirstCombinedEmoji', [$roomName]));
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Tests\php\Service;
use OCA\Talk\Service\EmojiService;
use OCP\IEmojiHelper;
use OCP\Server;
use Test\TestCase;
/**
* @group DB
*/
class EmojiServiceTest extends TestCase {
protected ?EmojiService $service = null;
public function setUp(): void {
parent::setUp();
$this->service = new EmojiService(
Server::get(IEmojiHelper::class),
);
}
public static function dataGetFirstCombinedEmoji(): array {
return [
['👋 Hello', '👋'],
['Only leading emojis 🚀', ''],
['👩🏽‍💻👩🏻‍💻👨🏿‍💻 Only one, but with all attributes', '👩🏽‍💻'],
];
}
/**
* @dataProvider dataGetFirstCombinedEmoji
*/
public function testGetFirstCombinedEmoji(string $roomName, string $avatarEmoji): void {
$this->assertSame($avatarEmoji, self::invokePrivate($this->service, 'getFirstCombinedEmoji', [$roomName]));
}
}

View file

@ -18,6 +18,7 @@ use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\BreakoutRoom;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCA\Talk\Service\EmojiService;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\RecordingService;
use OCA\Talk\Service\RoomService;
@ -29,6 +30,7 @@ use OCP\IDBConnection;
use OCP\IL10N;
use OCP\IUser;
use OCP\Security\IHasher;
use OCP\Server;
use OCP\Share\IManager as IShareManager;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
@ -48,6 +50,7 @@ class RoomServiceTest extends TestCase {
protected IJobList&MockObject $jobList;
protected LoggerInterface&MockObject $logger;
protected IL10N&MockObject $l10n;
protected EmojiService $emojiService;
protected ?RoomService $service = null;
public function setUp(): void {
@ -63,6 +66,7 @@ class RoomServiceTest extends TestCase {
$this->jobList = $this->createMock(IJobList::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->l10n = $this->createMock(IL10N::class);
$this->emojiService = Server::get(EmojiService::class);
$this->service = new RoomService(
$this->manager,
$this->participantService,
@ -73,6 +77,7 @@ class RoomServiceTest extends TestCase {
$this->hasher,
$this->dispatcher,
$this->jobList,
$this->emojiService,
$this->logger,
$this->l10n,
);
@ -225,8 +230,8 @@ class RoomServiceTest extends TestCase {
public static function dataCreateConversationInvalidObjects(): array {
return [
[str_repeat('a', 65), 'a', 'object_type'],
['a', str_repeat('a', 65), 'object_id'],
[str_repeat('a', 65), 'a', 'object-type'],
['a', str_repeat('a', 65), 'object-id'],
['a', '', 'object'],
['', 'b', 'object'],
];
@ -247,9 +252,10 @@ class RoomServiceTest extends TestCase {
public static function dataCreateConversation(): array {
return [
[Room::TYPE_GROUP, 'Group conversation', 'admin', '', '', ''],
[Room::TYPE_PUBLIC, 'Public conversation', '', 'files', '123456', ''],
[Room::TYPE_PUBLIC, 'Public conversation', '', 'files', '123456', 'AGoodPassword123?'],
[Room::TYPE_CHANGELOG, 'Talk updates ✅', 'test1', 'changelog', 'conversation', ''],
[Room::TYPE_PUBLIC, 'Public conversation', '', 'file', '123456', ''],
[Room::TYPE_PUBLIC, 'Public conversation', '', 'file', '123456', 'AGoodPassword123?'],
[Room::TYPE_CHANGELOG, 'Talk updates ✅', 'test1', '', '', ''],
[Room::TYPE_GROUP, 'Let\'s get started!', 'test1', 'sample', 'test1', ''],
];
}
@ -341,6 +347,7 @@ class RoomServiceTest extends TestCase {
$this->hasher,
$dispatcher,
$this->jobList,
$this->emojiService,
$this->logger,
$this->l10n,
);