From ef2eb5cbb5de5c9ac536b7d8ef4904764ea1f6e6 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Tue, 16 Jun 2026 07:37:46 +0200 Subject: [PATCH 1/4] feat(conversation): Allow to preserve a conversation as owner Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Joas Schilling --- docs/capabilities.md | 3 + docs/constants.md | 1 + lib/Capabilities.php | 1 + lib/Chat/Parser/SystemMessage.php | 14 + lib/Chat/SystemMessage/Listener.php | 13 + lib/Command/Room/Create.php | 10 + lib/Command/Room/TRoomCommand.php | 4 + lib/Command/Room/Update.php | 15 + lib/Controller/ChatController.php | 5 + lib/Controller/RoomController.php | 82 ++- lib/Events/ARoomModifiedEvent.php | 9 +- lib/Room.php | 4 + lib/RoomAttributes.php | 1 + lib/Service/RoomFormatter.php | 1 + lib/Service/RoomService.php | 35 ++ openapi-full.json | 466 ++++++++++++++++++ openapi.json | 466 ++++++++++++++++++ .../features/bootstrap/FeatureContext.php | 10 + .../features/conversation-5/preserve.feature | 54 ++ tests/php/Service/RoomServiceTest.php | 85 ++++ 20 files changed, 1271 insertions(+), 8 deletions(-) create mode 100644 tests/integration/features/conversation-5/preserve.feature diff --git a/docs/capabilities.md b/docs/capabilities.md index cae4f691aa3..5a3ba6ab471 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -227,3 +227,6 @@ * `private-reply` - Whether clients can link the original message to a private reply in one-to-one conversations * `config => attachments => conversation-subfolders` (local) - Whether per-conversation subfolders are used for Talk attachments; when `true` files must be uploaded to `Talk/-/-/` before calling the attachment endpoint * `conversation-tags` (local) - Whether the user can create custom tags to organize conversations in the sidebar + +## 25 +* `preserve-conversation` - Whether the owner can preserve a conversation diff --git a/docs/constants.md b/docs/constants.md index 50c26d6d7f3..b39b1735df6 100644 --- a/docs/constants.md +++ b/docs/constants.md @@ -76,6 +76,7 @@ Required capability: `conversation-presets` * `0` None * `1` Voice rooms - Join call when joining conversation +* `2` Preserved - Conversation can not be deleted, its chat history can not be cleared and the guests (public link) and joinable (listable) settings can not be changed (only owners can toggle this attribute, requires capability `preserve-conversation`) ## Participants diff --git a/lib/Capabilities.php b/lib/Capabilities.php index a3f3cbbf2e5..ab9e2eebef6 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -133,6 +133,7 @@ class Capabilities implements IPublicCapability { 'conversation-presets', 'private-reply', 'conversation-tags', + 'preserve-conversation', ]; public const CONDITIONAL_FEATURES = [ diff --git a/lib/Chat/Parser/SystemMessage.php b/lib/Chat/Parser/SystemMessage.php index 67ff026f2d2..6311c0eeafb 100644 --- a/lib/Chat/Parser/SystemMessage.php +++ b/lib/Chat/Parser/SystemMessage.php @@ -280,6 +280,20 @@ protected function parseMessage(Message $chatMessage, $allowInaccurate): void { } elseif ($cliIsActor) { $parsedMessage = $this->l->t('An administrator opened the conversation to registered users and users created with the Guests app'); } + } elseif ($message === 'preserve_conversation') { + $parsedMessage = $this->l->t('{actor} preserved the conversation'); + if ($currentUserIsActor) { + $parsedMessage = $this->l->t('You preserved the conversation'); + } elseif ($cliIsActor) { + $parsedMessage = $this->l->t('An administrator preserved the conversation'); + } + } elseif ($message === 'preserve_conversation_off') { + $parsedMessage = $this->l->t('{actor} stopped preserving the conversation'); + if ($currentUserIsActor) { + $parsedMessage = $this->l->t('You stopped preserving the conversation'); + } elseif ($cliIsActor) { + $parsedMessage = $this->l->t('An administrator stopped preserving the conversation'); + } } elseif ($message === 'lobby_timer_reached') { $parsedMessage = $this->l->t('The conversation is now open to everyone'); } elseif ($message === 'lobby_none') { diff --git a/lib/Chat/SystemMessage/Listener.php b/lib/Chat/SystemMessage/Listener.php index 79ea5d4e0c4..1bf58b4c99d 100644 --- a/lib/Chat/SystemMessage/Listener.php +++ b/lib/Chat/SystemMessage/Listener.php @@ -100,6 +100,7 @@ public function handle(Event $event): void { ARoomModifiedEvent::PROPERTY_MESSAGE_EXPIRATION => $this->afterSetMessageExpiration($event), ARoomModifiedEvent::PROPERTY_NAME => $this->sendSystemMessageAboutConversationRenamed($event), ARoomModifiedEvent::PROPERTY_PASSWORD => $this->sendSystemMessageAboutRoomPassword($event), + ARoomModifiedEvent::PROPERTY_PRESERVE_CONVERSATION => $this->sendSystemPreserveConversationMessage($event), ARoomModifiedEvent::PROPERTY_READ_ONLY => $this->sendSystemReadOnlyMessage($event), ARoomModifiedEvent::PROPERTY_TYPE => $this->sendSystemGuestPermissionsMessage($event), default => null, @@ -268,6 +269,18 @@ protected function sendSystemListableMessage(RoomModifiedEvent $event): void { } } + protected function sendSystemPreserveConversationMessage(RoomModifiedEvent $event): void { + if ($event->getNewValue() === $event->getOldValue()) { + return; + } + + if ($event->getNewValue()) { + $this->sendSystemMessage($event->getRoom(), 'preserve_conversation'); + } else { + $this->sendSystemMessage($event->getRoom(), 'preserve_conversation_off'); + } + } + protected function sendSystemLobbyMessage(LobbyModifiedEvent $event): void { if ($event->getNewValue() === $event->getOldValue()) { return; diff --git a/lib/Command/Room/Create.php b/lib/Command/Room/Create.php index 66e7fd8b51f..0bc9a9bbce8 100644 --- a/lib/Command/Room/Create.php +++ b/lib/Command/Room/Create.php @@ -79,6 +79,11 @@ protected function configure(): void { null, InputOption::VALUE_REQUIRED, 'Seconds to expire a message after sent. If zero will disable the expire message duration.' + )->addOption( + 'preserve', + null, + InputOption::VALUE_NONE, + 'Preserves the room so it can not be deleted, its history cleared or its guests/joinable settings changed' ); } @@ -94,6 +99,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $owner = $input->getOption('owner'); $moderators = $input->getOption('moderator'); $messageExpiration = $input->getOption('message-expiration'); + $preserve = $input->getOption('preserve'); if (!in_array($listable, [ null, @@ -139,6 +145,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($messageExpiration !== null) { $this->setMessageExpiration($room, (int)$messageExpiration); } + + if ($preserve) { + $this->setRoomPreserve($room, true); + } } catch (InvalidArgumentException $e) { $this->roomService->deleteRoom($room); diff --git a/lib/Command/Room/TRoomCommand.php b/lib/Command/Room/TRoomCommand.php index 8fcde102a77..5c112bd6976 100644 --- a/lib/Command/Room/TRoomCommand.php +++ b/lib/Command/Room/TRoomCommand.php @@ -149,6 +149,10 @@ protected function setRoomListable(Room $room, int $listable): void { } } + protected function setRoomPreserve(Room $room, bool $preserve): void { + $this->roomService->setPreserveConversation($room, $preserve); + } + /** * @param Room $room * @param string $password diff --git a/lib/Command/Room/Update.php b/lib/Command/Room/Update.php index e562cd12930..d4847b95d2f 100644 --- a/lib/Command/Room/Update.php +++ b/lib/Command/Room/Update.php @@ -70,6 +70,11 @@ protected function configure(): void { null, InputOption::VALUE_REQUIRED, 'Seconds to expire a message after sent. If zero will disable the expire message duration.' + )->addOption( + 'preserve', + null, + InputOption::VALUE_REQUIRED, + 'Preserves the room (value 1) so it can not be deleted, its history cleared or its guests/joinable settings changed; pass value 0 to remove the protection' ); } @@ -83,12 +88,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int $password = $input->getOption('password'); $owner = $input->getOption('owner'); $messageExpiration = $input->getOption('message-expiration'); + $preserve = $input->getOption('preserve'); if (!in_array($public, [null, '0', '1'], true)) { $output->writeln('Invalid value for option "--public" given.'); return 1; } + if (!in_array($preserve, [null, '0', '1'], true)) { + $output->writeln('Invalid value for option "--preserve" given.'); + return 1; + } + if (!in_array($readOnly, [null, (string)Room::READ_WRITE, (string)Room::READ_ONLY], true)) { $output->writeln('Invalid value for option "--readonly" given.'); return 1; @@ -157,6 +168,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($messageExpiration !== null) { $this->setMessageExpiration($room, (int)$messageExpiration); } + + if ($preserve !== null) { + $this->setRoomPreserve($room, ($preserve === '1')); + } } catch (InvalidArgumentException $e) { $output->writeln(sprintf('%s', $e->getMessage())); return 1; diff --git a/lib/Controller/ChatController.php b/lib/Controller/ChatController.php index 8784d8f722a..8fbf0ae7083 100644 --- a/lib/Controller/ChatController.php +++ b/lib/Controller/ChatController.php @@ -1842,6 +1842,11 @@ public function clearHistory(): DataResponse { return new DataResponse(null, Http::STATUS_FORBIDDEN); } + if ($this->room->isPreserved()) { + // Not allowed to purge a preserved conversation + return new DataResponse(null, Http::STATUS_FORBIDDEN); + } + if (!$this->appConfig->getAppValueBool('delete_one_to_one_conversations') && ($this->room->getType() === Room::TYPE_ONE_TO_ONE || $this->room->getType() === Room::TYPE_ONE_TO_ONE_FORMER)) { diff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php index 0d8a2c56ac1..3a808e59a21 100644 --- a/lib/Controller/RoomController.php +++ b/lib/Controller/RoomController.php @@ -1026,10 +1026,11 @@ public function setDescription(string $description): DataResponse { /** * Delete a room * - * @return DataResponse + * @return DataResponse|DataResponse * * 200: Room successfully deleted * 400: Deleting room is not possible + * 403: Conversation is preserved */ #[PublicPage] #[RequireModeratorParticipant] @@ -1038,6 +1039,10 @@ public function setDescription(string $description): DataResponse { 'token' => '[a-z0-9]{4,30}', ])] public function deleteRoom(): DataResponse { + if ($this->room->isPreserved()) { + return new DataResponse(['error' => 'preserved'], Http::STATUS_FORBIDDEN); + } + if (!$this->appConfig->getAppValueBool('delete_one_to_one_conversations') && in_array($this->room->getType(), [Room::TYPE_ONE_TO_ONE, Room::TYPE_ONE_TO_ONE_FORMER], true)) { return new DataResponse(null, Http::STATUS_BAD_REQUEST); @@ -1675,10 +1680,11 @@ public function removeAttendeeFromRoom(int $attendeeId): DataResponse { * Required capability: `conversation-creation-password` for `string $password` parameter * * @param string $password New password (only available with `conversation-creation-password` capability) - * @return DataResponse|DataResponse + * @return DataResponse|DataResponse|DataResponse * * 200: Allowed guests successfully * 400: Allowing guests is not possible + * 403: Conversation is preserved */ #[NoAdminRequired] #[RequireLoggedInModeratorParticipant] @@ -1687,6 +1693,10 @@ public function removeAttendeeFromRoom(int $attendeeId): DataResponse { 'token' => '[a-z0-9]{4,30}', ])] public function makePublic(string $password = ''): DataResponse { + if ($this->room->isPreserved()) { + return new DataResponse(['error' => 'preserved'], Http::STATUS_FORBIDDEN); + } + if ($this->talkConfig->isPasswordEnforced() && $password === '') { return new DataResponse(['error' => 'password', 'message' => $this->l->t('Password needs to be set')], Http::STATUS_BAD_REQUEST); } @@ -1709,10 +1719,11 @@ public function makePublic(string $password = ''): DataResponse { /** * Disallowed guests to join conversation * - * @return DataResponse|DataResponse + * @return DataResponse|DataResponse|DataResponse * * 200: Room unpublished Disallowing guests successfully * 400: Disallowing guests is not possible + * 403: Conversation is preserved */ #[NoAdminRequired] #[RequireLoggedInModeratorParticipant] @@ -1721,6 +1732,10 @@ public function makePublic(string $password = ''): DataResponse { 'token' => '[a-z0-9]{4,30}', ])] public function makePrivate(): DataResponse { + if ($this->room->isPreserved()) { + return new DataResponse(['error' => 'preserved'], Http::STATUS_FORBIDDEN); + } + try { $this->roomService->setType($this->room, Room::TYPE_GROUP); } catch (TypeException $e) { @@ -1770,10 +1785,11 @@ public function setReadOnly(int $state): DataResponse { * * @param 0|1|2 $scope Scope where the room is listable * @psalm-param Room::LISTABLE_* $scope - * @return DataResponse|DataResponse + * @return DataResponse|DataResponse|DataResponse * * 200: Made room listable successfully * 400: Making room listable is not possible + * 403: Conversation is preserved */ #[NoAdminRequired] #[RequireModeratorParticipant] @@ -1782,6 +1798,10 @@ public function setReadOnly(int $state): DataResponse { 'token' => '[a-z0-9]{4,30}', ])] public function setListable(int $scope): DataResponse { + if ($this->room->isPreserved()) { + return new DataResponse(['error' => 'preserved'], Http::STATUS_FORBIDDEN); + } + /** @var Room::LISTABLE_* $forced */ $forced = $this->forcedParameters->getForcedParameter(Parameter::LISTABLE); if ($forced !== null && $forced !== $scope) { @@ -1900,6 +1920,60 @@ public function unarchiveConversation(): DataResponse { return new DataResponse($this->formatRoom($this->room, $this->participant)); } + /** + * Preserve a conversation + * + * While preserved the conversation can not be deleted, its chat history can + * not be cleared and the guests (public link) and joinable (listable) + * settings can not be changed. + * + * Required capability: `preserve-conversation` + * + * @return DataResponse|DataResponse + * + * 200: Conversation was preserved + * 403: Only the owner can preserve a conversation + */ + #[NoAdminRequired] + #[RequireLoggedInModeratorParticipant] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/preserve', requirements: [ + 'apiVersion' => '(v4)', + 'token' => '[a-z0-9]{4,30}', + ])] + public function preserveConversation(): DataResponse { + if ($this->participant->getAttendee()->getParticipantType() !== Participant::OWNER) { + return new DataResponse(['error' => 'permissions'], Http::STATUS_FORBIDDEN); + } + + $this->roomService->setPreserveConversation($this->room, true); + return new DataResponse($this->formatRoom($this->room, $this->participant)); + } + + /** + * Stop preserving a conversation + * + * Required capability: `preserve-conversation` + * + * @return DataResponse|DataResponse + * + * 200: Conversation is not preserved anymore + * 403: Only the owner can stop preserving a conversation + */ + #[NoAdminRequired] + #[RequireLoggedInModeratorParticipant] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/room/{token}/preserve', requirements: [ + 'apiVersion' => '(v4)', + 'token' => '[a-z0-9]{4,30}', + ])] + public function unpreserveConversation(): DataResponse { + if ($this->participant->getAttendee()->getParticipantType() !== Participant::OWNER) { + return new DataResponse(['error' => 'permissions'], Http::STATUS_FORBIDDEN); + } + + $this->roomService->setPreserveConversation($this->room, false); + return new DataResponse($this->formatRoom($this->room, $this->participant)); + } + /** * Assign conversation tags * diff --git a/lib/Events/ARoomModifiedEvent.php b/lib/Events/ARoomModifiedEvent.php index fb7714d27cf..880b4b93060 100644 --- a/lib/Events/ARoomModifiedEvent.php +++ b/lib/Events/ARoomModifiedEvent.php @@ -28,6 +28,7 @@ abstract class ARoomModifiedEvent extends ARoomEvent { public const PROPERTY_MENTION_PERMISSIONS = 'mentionPermissions'; public const PROPERTY_NAME = 'name'; public const PROPERTY_PASSWORD = 'password'; + public const PROPERTY_PRESERVE_CONVERSATION = 'preserveConversation'; public const PROPERTY_READ_ONLY = 'readOnly'; public const PROPERTY_RECORDING_CONSENT = 'recordingConsent'; public const PROPERTY_SIP_ENABLED = 'sipEnabled'; @@ -39,8 +40,8 @@ abstract class ARoomModifiedEvent extends ARoomEvent { public function __construct( Room $room, protected string $property, - protected \DateTime|string|int|null $newValue, - protected \DateTime|string|int|null $oldValue = null, + protected \DateTime|string|int|bool|null $newValue, + protected \DateTime|string|int|bool|null $oldValue = null, protected ?Participant $actor = null, ) { parent::__construct($room); @@ -50,11 +51,11 @@ public function getProperty(): string { return $this->property; } - public function getNewValue(): \DateTime|string|int|null { + public function getNewValue(): \DateTime|string|int|bool|null { return $this->newValue; } - public function getOldValue(): \DateTime|string|int|null { + public function getOldValue(): \DateTime|string|int|bool|null { return $this->oldValue; } diff --git a/lib/Room.php b/lib/Room.php index 503688979da..c8bf2f6232b 100644 --- a/lib/Room.php +++ b/lib/Room.php @@ -484,4 +484,8 @@ public function getAttributes(): int { public function setAttributes(int $attributes): void { $this->attributes = $attributes; } + + public function isPreserved(): bool { + return ($this->attributes & RoomAttributes::PRESERVE_CONVERSATION->value) === RoomAttributes::PRESERVE_CONVERSATION->value; + } } diff --git a/lib/RoomAttributes.php b/lib/RoomAttributes.php index c9520af816f..d9defa63a3a 100644 --- a/lib/RoomAttributes.php +++ b/lib/RoomAttributes.php @@ -11,4 +11,5 @@ enum RoomAttributes: int { case NONE = 0; case VOICE_ROOM = 1; + case PRESERVE_CONVERSATION = 2; } diff --git a/lib/Service/RoomFormatter.php b/lib/Service/RoomFormatter.php index dc7b17768d2..a7a13b0a5ed 100644 --- a/lib/Service/RoomFormatter.php +++ b/lib/Service/RoomFormatter.php @@ -338,6 +338,7 @@ public function formatRoomV4( $roomData['canDeleteConversation'] = $room->getType() !== Room::TYPE_ONE_TO_ONE && $room->getType() !== Room::TYPE_ONE_TO_ONE_FORMER + && !$room->isPreserved() && $currentParticipant->hasModeratorPermissions(false); $roomData['canLeaveConversation'] = $room->getType() !== Room::TYPE_NOTE_TO_SELF; diff --git a/lib/Service/RoomService.php b/lib/Service/RoomService.php index 705857d2918..30d7d7c4281 100644 --- a/lib/Service/RoomService.php +++ b/lib/Service/RoomService.php @@ -837,6 +837,41 @@ public function setListable(Room $room, int $newState): void { $this->dispatcher->dispatchTyped($event); } + /** + * Mark a conversation as preserved (or remove the mark again). + * + * While preserved the conversation can not be deleted, its chat history can + * not be cleared and the "guests" (public link) and "joinable" (listable) + * settings can not be changed via the API. + */ + public function setPreserveConversation(Room $room, bool $preserve): void { + $oldPreserve = $room->isPreserved(); + if ($preserve === $oldPreserve) { + return; + } + + $oldAttributes = $room->getAttributes(); + if ($preserve) { + $newAttributes = $oldAttributes | RoomAttributes::PRESERVE_CONVERSATION->value; + } else { + $newAttributes = $oldAttributes & ~RoomAttributes::PRESERVE_CONVERSATION->value; + } + + $event = new BeforeRoomModifiedEvent($room, ARoomModifiedEvent::PROPERTY_PRESERVE_CONVERSATION, $preserve, $oldPreserve); + $this->dispatcher->dispatchTyped($event); + + $update = $this->db->getQueryBuilder(); + $update->update('talk_rooms') + ->set('attributes', $update->createNamedParameter($newAttributes, IQueryBuilder::PARAM_INT)) + ->where($update->expr()->eq('id', $update->createNamedParameter($room->getId(), IQueryBuilder::PARAM_INT))); + $update->executeStatement(); + + $room->setAttributes($newAttributes); + + $event = new RoomModifiedEvent($room, ARoomModifiedEvent::PROPERTY_PRESERVE_CONVERSATION, $preserve, $oldPreserve); + $this->dispatcher->dispatchTyped($event); + } + /** * @param Room $room * @param int $newState New mention permissions from Room::MENTION_PERMISSIONS_* diff --git a/openapi-full.json b/openapi-full.json index 3cddb5c40ab..168505b024d 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -19888,6 +19888,47 @@ } } } + }, + "403": { + "description": "Conversation is preserved", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "preserved" + ] + } + } + } + } + } + } + } + } + } } } } @@ -22013,6 +22054,47 @@ } } }, + "403": { + "description": "Conversation is preserved", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "preserved" + ] + } + } + } + } + } + } + } + } + } + }, "401": { "description": "Current user is not logged in", "content": { @@ -22164,6 +22246,47 @@ } } }, + "403": { + "description": "Conversation is preserved", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "preserved" + ] + } + } + } + } + } + } + } + } + } + }, "401": { "description": "Current user is not logged in", "content": { @@ -22545,6 +22668,47 @@ } } }, + "403": { + "description": "Conversation is preserved", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "preserved" + ] + } + } + } + } + } + } + } + } + } + }, "401": { "description": "Current user is not logged in", "content": { @@ -23147,6 +23311,308 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/preserve": { + "post": { + "operationId": "room-preserve-conversation", + "summary": "Preserve a conversation", + "description": "While preserved the conversation can not be deleted, its chat history can not be cleared and the guests (public link) and joinable (listable) settings can not be changed.\nRequired capability: `preserve-conversation`", + "tags": [ + "room" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Conversation was preserved", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Room" + } + } + } + } + } + } + } + }, + "403": { + "description": "Only the owner can preserve a conversation", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "permissions" + ] + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "room-unpreserve-conversation", + "summary": "Stop preserving a conversation", + "description": "Required capability: `preserve-conversation`", + "tags": [ + "room" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Conversation is not preserved anymore", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Room" + } + } + } + } + } + } + } + }, + "403": { + "description": "Only the owner can stop preserving a conversation", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "permissions" + ] + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/tags": { "post": { "operationId": "room-assign-tags", diff --git a/openapi.json b/openapi.json index 88a70a97ff4..bec682420ad 100644 --- a/openapi.json +++ b/openapi.json @@ -19776,6 +19776,47 @@ } } } + }, + "403": { + "description": "Conversation is preserved", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "preserved" + ] + } + } + } + } + } + } + } + } + } } } } @@ -21901,6 +21942,47 @@ } } }, + "403": { + "description": "Conversation is preserved", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "preserved" + ] + } + } + } + } + } + } + } + } + } + }, "401": { "description": "Current user is not logged in", "content": { @@ -22052,6 +22134,47 @@ } } }, + "403": { + "description": "Conversation is preserved", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "preserved" + ] + } + } + } + } + } + } + } + } + } + }, "401": { "description": "Current user is not logged in", "content": { @@ -22433,6 +22556,47 @@ } } }, + "403": { + "description": "Conversation is preserved", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "preserved" + ] + } + } + } + } + } + } + } + } + } + }, "401": { "description": "Current user is not logged in", "content": { @@ -23035,6 +23199,308 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/preserve": { + "post": { + "operationId": "room-preserve-conversation", + "summary": "Preserve a conversation", + "description": "While preserved the conversation can not be deleted, its chat history can not be cleared and the guests (public link) and joinable (listable) settings can not be changed.\nRequired capability: `preserve-conversation`", + "tags": [ + "room" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Conversation was preserved", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Room" + } + } + } + } + } + } + } + }, + "403": { + "description": "Only the owner can preserve a conversation", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "permissions" + ] + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "room-unpreserve-conversation", + "summary": "Stop preserving a conversation", + "description": "Required capability: `preserve-conversation`", + "tags": [ + "room" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Conversation is not preserved anymore", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Room" + } + } + } + } + } + } + } + }, + "403": { + "description": "Only the owner can stop preserving a conversation", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "permissions" + ] + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/tags": { "post": { "operationId": "room-assign-tags", diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index df7867410dd..99b56015ef9 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -1788,6 +1788,16 @@ public function userChangesTypeOfTheRoom(string $user, string $identifier, strin $this->assertStatusCode($this->response, $statusCode); } + #[Then('/^user "([^"]*)" (preserves|stops preserving) room "([^"]*)" with (\d+) \((v4)\)$/')] + public function userChangesPreserveStateOfTheRoom(string $user, string $newState, string $identifier, int $statusCode, string $apiVersion): void { + $this->setCurrentUser($user); + $this->sendRequest( + $newState === 'preserves' ? 'POST' : 'DELETE', + '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/preserve' + ); + $this->assertStatusCode($this->response, $statusCode); + } + #[Then('/^user "([^"]*)" (locks|unlocks) room "([^"]*)" with (\d+) \((v4)\)$/')] public function userChangesReadOnlyStateOfTheRoom(string $user, string $newState, string $identifier, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); diff --git a/tests/integration/features/conversation-5/preserve.feature b/tests/integration/features/conversation-5/preserve.feature new file mode 100644 index 00000000000..434754dfa79 --- /dev/null +++ b/tests/integration/features/conversation-5/preserve.feature @@ -0,0 +1,54 @@ +Feature: conversation-5/preserve + Background: + Given user "participant1" exists + Given user "participant2" exists + Given user "participant3" exists + + Scenario: Owner can preserve and stop preserving a conversation + Given user "participant1" creates room "room" (v4) + | roomType | 2 | + | roomName | room | + When user "participant1" preserves room "room" with 200 (v4) + Then user "participant1" is participant of the following rooms (v4) + | id | type | attributes | + | room | 2 | 2 | + When user "participant1" stops preserving room "room" with 200 (v4) + Then user "participant1" is participant of the following rooms (v4) + | id | type | + | room | 2 | + + Scenario: A preserved conversation can not be deleted, cleared or have its guests/joinable settings changed + Given user "participant1" creates room "room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" preserves room "room" with 200 (v4) + Then user "participant1" deletes room "room" with 403 (v4) + And user "participant1" deletes chat history for room "room" with 403 (v1) + And user "participant1" makes room "room" public with 403 (v4) + And user "participant1" allows listing room "room" for "all" with 403 (v4) + When user "participant1" stops preserving room "room" with 200 (v4) + Then user "participant1" makes room "room" public with 200 (v4) + And user "participant1" allows listing room "room" for "all" with 200 (v4) + And user "participant1" deletes chat history for room "room" with 200 (v1) + And user "participant1" deletes room "room" with 200 (v4) + + Scenario: A preserved public conversation can not be made private + Given user "participant1" creates room "room" (v4) + | roomType | 3 | + | roomName | room | + And user "participant1" preserves room "room" with 200 (v4) + Then user "participant1" makes room "room" private with 403 (v4) + + Scenario: Only the owner can preserve a conversation + Given user "participant1" creates room "room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" adds user "participant2" to room "room" with 200 (v4) + And user "participant1" adds user "participant3" to room "room" with 200 (v4) + And user "participant1" promotes "participant2" in room "room" with 200 (v4) + Then user "participant2" preserves room "room" with 403 (v4) + And user "participant3" preserves room "room" with 403 (v4) + And user "participant1" preserves room "room" with 200 (v4) + And user "participant2" stops preserving room "room" with 403 (v4) + And user "participant3" stops preserving room "room" with 403 (v4) + And user "participant1" stops preserving room "room" with 200 (v4) diff --git a/tests/php/Service/RoomServiceTest.php b/tests/php/Service/RoomServiceTest.php index b58c2ed2564..3cc82a4e45f 100644 --- a/tests/php/Service/RoomServiceTest.php +++ b/tests/php/Service/RoomServiceTest.php @@ -18,6 +18,7 @@ use OCA\Talk\Model\BreakoutRoom; use OCA\Talk\Participant; use OCA\Talk\Room; +use OCA\Talk\RoomAttributes; use OCA\Talk\Service\EmojiService; use OCA\Talk\Service\ParticipantService; use OCA\Talk\Service\RecordingService; @@ -419,4 +420,88 @@ public function testVerifyPassword(): void { $this->assertSame($verificationResult, ['result' => false, 'url' => 'https://test']); $this->assertSame('passy', $room->getPassword()); } + + protected function createRoomWithAttributes(int $attributes): Room { + return new Room( + 0, + Room::TYPE_PUBLIC, + Room::READ_WRITE, + Room::LISTABLE_NONE, + 0, + Webinary::LOBBY_NONE, + Webinary::SIP_DISABLED, + null, + 'foobar', + 'Test', + 'description', + '', + '', + '', + '', + Attendee::PERMISSIONS_DEFAULT, + Participant::FLAG_DISCONNECTED, + null, + null, + 0, + null, + null, + '', + '', + BreakoutRoom::MODE_NOT_CONFIGURED, + BreakoutRoom::STATUS_STOPPED, + Room::RECORDING_NONE, + RecordingService::CONSENT_REQUIRED_NO, + Room::HAS_FEDERATION_NONE, + Room::MENTION_PERMISSIONS_EVERYONE, + '', + 0, + $attributes, + ); + } + + public function testSetPreserveConversationEnables(): void { + $room = $this->createRoomWithAttributes(RoomAttributes::NONE->value); + $this->assertFalse($room->isPreserved()); + + $this->dispatcher->expects($this->exactly(2)) + ->method('dispatchTyped'); + + $this->service->setPreserveConversation($room, true); + + $this->assertTrue($room->isPreserved()); + $this->assertSame(RoomAttributes::PRESERVE_CONVERSATION->value, $room->getAttributes() & RoomAttributes::PRESERVE_CONVERSATION->value); + } + + public function testSetPreserveConversationDisables(): void { + $room = $this->createRoomWithAttributes(RoomAttributes::PRESERVE_CONVERSATION->value); + $this->assertTrue($room->isPreserved()); + + $this->dispatcher->expects($this->exactly(2)) + ->method('dispatchTyped'); + + $this->service->setPreserveConversation($room, false); + + $this->assertFalse($room->isPreserved()); + $this->assertSame(0, $room->getAttributes() & RoomAttributes::PRESERVE_CONVERSATION->value); + } + + public function testSetPreserveConversationKeepsOtherAttributes(): void { + $room = $this->createRoomWithAttributes(RoomAttributes::VOICE_ROOM->value); + + $this->service->setPreserveConversation($room, true); + + $this->assertTrue($room->isPreserved()); + $this->assertSame(RoomAttributes::VOICE_ROOM->value, $room->getAttributes() & RoomAttributes::VOICE_ROOM->value, 'Voice room attribute must be kept'); + } + + public function testSetPreserveConversationIsNoopWhenUnchanged(): void { + $room = $this->createRoomWithAttributes(RoomAttributes::PRESERVE_CONVERSATION->value); + + $this->dispatcher->expects($this->never()) + ->method('dispatchTyped'); + + $this->service->setPreserveConversation($room, true); + + $this->assertTrue($room->isPreserved()); + } } From 315213d3057271b2eae2c5887944a6c4a9633870 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Tue, 16 Jun 2026 07:39:38 +0200 Subject: [PATCH 2/4] feat(conversation): Preserve option in the UI Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Joas Schilling --- .../ConversationSettingsDialog.vue | 11 +- .../ConversationSettings/DangerZone.vue | 13 ++ .../LinkShareSettings.vue | 9 +- .../ConversationSettings/ListableSettings.vue | 11 +- .../PreserveConversationSettings.vue | 115 ++++++++++ .../StopPreservingDialog.vue | 118 ++++++++++ src/constants.ts | 3 + src/services/conversationsService.ts | 22 ++ src/store/conversationsStore.js | 18 ++ src/types/index.ts | 2 + src/types/openapi/openapi-full.ts | 217 ++++++++++++++++++ src/types/openapi/openapi.ts | 217 ++++++++++++++++++ 12 files changed, 752 insertions(+), 4 deletions(-) create mode 100644 src/components/ConversationSettings/PreserveConversationSettings.vue create mode 100644 src/components/ConversationSettings/StopPreservingDialog.vue diff --git a/src/components/ConversationSettings/ConversationSettingsDialog.vue b/src/components/ConversationSettings/ConversationSettingsDialog.vue index 0fd412611f7..f111bb40010 100644 --- a/src/components/ConversationSettings/ConversationSettingsDialog.vue +++ b/src/components/ConversationSettings/ConversationSettingsDialog.vue @@ -103,9 +103,10 @@ +