From ef3265c9c7efe12982394965c0429489e6c9ef0d Mon Sep 17 00:00:00 2001 From: Anna Larch Date: Sat, 8 Nov 2025 12:58:40 +0100 Subject: [PATCH] feat: scheduled message API Signed-off-by: Anna Larch --- appinfo/info.xml | 2 +- docs/capabilities.md | 1 + lib/Capabilities.php | 2 + lib/Chat/Listener.php | 3 + lib/Controller/ChatController.php | 258 +++++ lib/Controller/RoomController.php | 3 +- lib/Listener/UserDeletedListener.php | 4 + .../Version23000Date20251105125333.php | 91 ++ lib/Model/Attendee.php | 5 + lib/Model/Message.php | 1 + lib/Model/ScheduledMessage.php | 145 +++ lib/Model/ScheduledMessageMapper.php | 137 +++ lib/Model/SelectHelper.php | 27 + lib/Participant.php | 9 + lib/ResponseDefinitions.php | 26 + lib/Service/ParticipantService.php | 6 + lib/Service/RoomFormatter.php | 2 + lib/Service/ScheduledMessageService.php | 200 ++++ openapi-backend-sipbridge.json | 7 +- openapi-federation.json | 7 +- openapi-full.json | 1019 ++++++++++++++++- openapi.json | 1019 ++++++++++++++++- .../openapi/openapi-backend-sipbridge.ts | 2 + src/types/openapi/openapi-federation.ts | 2 + src/types/openapi/openapi-full.ts | 475 ++++++++ src/types/openapi/openapi.ts | 475 ++++++++ .../features/bootstrap/FeatureContext.php | 134 +++ .../chat-4/scheduled-messages.feature | 111 ++ .../lib/Controller/ApiController.php | 3 + tests/php/Controller/ChatControllerTest.php | 26 +- 30 files changed, 4181 insertions(+), 21 deletions(-) create mode 100644 lib/Migration/Version23000Date20251105125333.php create mode 100644 lib/Model/ScheduledMessage.php create mode 100644 lib/Model/ScheduledMessageMapper.php create mode 100644 lib/Service/ScheduledMessageService.php create mode 100644 tests/integration/features/chat-4/scheduled-messages.feature diff --git a/appinfo/info.xml b/appinfo/info.xml index 72bdcbd1c2..40d1e52ef2 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -18,7 +18,7 @@ * 🌉 **Sync with other chat solutions** With [Matterbridge](https://github.com/42wim/matterbridge/) being integrated in Talk, you can easily sync a lot of other chat solutions to Nextcloud Talk and vice-versa. ]]> - 23.0.0-dev.2 + 23.0.0-dev.3 agpl Anna Larch diff --git a/docs/capabilities.md b/docs/capabilities.md index e9a377011a..fcb35a7efd 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -202,3 +202,4 @@ * `pinned-messages` - Whether messages can be pinned * `federated-shared-items` - Whether shared items endpoints can be called in a federated conversation * `config => chat => style` (local) - User selected chat style (split or unified for now) +* `scheduled-messages` (local) - Whether a user can schedule messages diff --git a/lib/Capabilities.php b/lib/Capabilities.php index a46a19711b..b31e0a3803 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -126,6 +126,7 @@ class Capabilities implements IPublicCapability { 'threads', 'pinned-messages', 'federated-shared-items', + 'scheduled-messages', ]; public const CONDITIONAL_FEATURES = [ @@ -156,6 +157,7 @@ class Capabilities implements IPublicCapability { 'mutual-calendar-events', 'upcoming-reminders', 'sensitive-conversations', + 'scheduled-messages', ]; public const LOCAL_CONFIGS = [ diff --git a/lib/Chat/Listener.php b/lib/Chat/Listener.php index c902ef66ca..7e75ae00ce 100644 --- a/lib/Chat/Listener.php +++ b/lib/Chat/Listener.php @@ -10,6 +10,7 @@ declare(strict_types=1); namespace OCA\Talk\Chat; use OCA\Talk\Events\RoomDeletedEvent; +use OCA\Talk\Model\ScheduledMessageMapper; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; @@ -19,6 +20,7 @@ use OCP\EventDispatcher\IEventListener; class Listener implements IEventListener { public function __construct( protected ChatManager $chatManager, + protected ScheduledMessageMapper $scheduledMessageMapper, ) { } @@ -26,6 +28,7 @@ class Listener implements IEventListener { public function handle(Event $event): void { if ($event instanceof RoomDeletedEvent) { $this->chatManager->deleteMessages($event->getRoom()); + $this->scheduledMessageMapper->deleteMessagesByRoom($event->getRoom()); } } } diff --git a/lib/Controller/ChatController.php b/lib/Controller/ChatController.php index 11cf11c975..c2f81294fb 100644 --- a/lib/Controller/ChatController.php +++ b/lib/Controller/ChatController.php @@ -35,6 +35,7 @@ use OCA\Talk\Model\Attendee; use OCA\Talk\Model\Bot; use OCA\Talk\Model\Message; use OCA\Talk\Model\Reminder; +use OCA\Talk\Model\ScheduledMessage; use OCA\Talk\Model\Session; use OCA\Talk\Model\Thread; use OCA\Talk\Participant; @@ -47,6 +48,7 @@ use OCA\Talk\Service\ParticipantService; use OCA\Talk\Service\ProxyCacheMessageService; use OCA\Talk\Service\ReminderService; use OCA\Talk\Service\RoomFormatter; +use OCA\Talk\Service\ScheduledMessageService; use OCA\Talk\Service\SessionService; use OCA\Talk\Service\ThreadService; use OCA\Talk\Share\Helper\Preloader; @@ -93,6 +95,7 @@ use Psr\Log\LoggerInterface; * @psalm-import-type TalkChatReminderUpcoming from ResponseDefinitions * @psalm-import-type TalkRichObjectParameter from ResponseDefinitions * @psalm-import-type TalkRoom from ResponseDefinitions + * @psalm-import-type TalkScheduledMessage from ResponseDefinitions */ class ChatController extends AEnvironmentAwareOCSController { /** @var string[] */ @@ -135,6 +138,7 @@ class ChatController extends AEnvironmentAwareOCSController { protected ITaskProcessingManager $taskProcessingManager, protected IAppConfig $appConfig, protected LoggerInterface $logger, + protected ScheduledMessageService $scheduledMessageManager, ) { parent::__construct($appName, $request); } @@ -332,6 +336,260 @@ class ChatController extends AEnvironmentAwareOCSController { return $this->parseCommentToResponse($comment, $parentMessage); } + /** + * Get all scheduled messages of a given room and participant + * + * The author and timestamp are automatically set to the current user + * and time. + * + * Required capability: `scheduled-messages` + * + * @return DataResponse, array{}>|DataResponse + * + * 200: All scheduled messages for this room and participant + * 404: Actor not found + */ + #[NoAdminRequired] + #[RequireModeratorOrNoLobby] + #[RequireParticipant] + #[RequirePermission(permission: RequirePermission::CHAT)] + #[RequireReadWriteConversation] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/chat/{token}/schedule', requirements: [ + 'apiVersion' => '(v1)', + 'token' => '[a-z0-9]{4,30}', + ])] + public function getScheduledMessages(): DataResponse { + if ($this->participant->isSelfJoinedOrGuest()) { + return new DataResponse(['error' => 'actor'], Http::STATUS_NOT_FOUND); + } + + $scheduledMessages = $this->scheduledMessageManager->getMessages( + $this->room, + $this->participant, + ); + + return new DataResponse($scheduledMessages, Http::STATUS_OK); + } + + /** + * Schedules the sending of a new chat message to the given room + * + * The author and timestamp are automatically set to the current user + * and time. + * + * Required capability: `scheduled-messages` + * + * @param string $message The message to send + * @param int $sendAt When to send the scheduled message + * @param int $replyTo Parent id which this scheduled message is a reply to + * @psalm-param non-negative-int $replyTo + * @param bool $silent If sent silent the scheduled message will not create any notifications when sent + * @param string $threadTitle Only supported when not replying, when given will create a thread (requires `threads` capability) + * @param int $threadId Thread id without quoting a specific message (requires `threads` capability) + * @return DataResponse|DataResponse|DataResponse|DataResponse + * + * 201: Message scheduled successfully + * 400: Scheduling the message is not possible + * 404: Actor not found + * 413: Message too long + */ + #[NoAdminRequired] + #[RequireModeratorOrNoLobby] + #[RequireParticipant] + #[RequirePermission(permission: RequirePermission::CHAT)] + #[RequireReadWriteConversation] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/chat/{token}/schedule', requirements: [ + 'apiVersion' => '(v1)', + 'token' => '[a-z0-9]{4,30}', + ])] + public function scheduleMessage( + string $message, + int $sendAt, + int $replyTo = 0, + bool $silent = false, + string $threadTitle = '', + int $threadId = 0, + ): DataResponse { + if ($this->participant->isSelfJoinedOrGuest()) { + return new DataResponse(['error' => 'actor'], Http::STATUS_NOT_FOUND); + } + + if ($sendAt <= $this->timeFactory->getTime()) { + return new DataResponse(['error' => 'send-at'], Http::STATUS_BAD_REQUEST); + } + + if (trim($message) === '') { + return new DataResponse(['error' => 'message'], Http::STATUS_BAD_REQUEST); + } + + $parent = $parentMessage = null; + if ($replyTo !== 0) { + try { + $parent = $this->chatManager->getParentComment($this->room, (string)$replyTo); + } catch (NotFoundException $e) { + // Someone is trying to reply cross-rooms or to a non-existing message + return new DataResponse(['error' => 'reply-to'], Http::STATUS_BAD_REQUEST); + } + + $parentMessage = $this->messageParser->createMessage($this->room, $this->participant, $parent, $this->l); + $this->messageParser->parseMessage($parentMessage); + if (!$parentMessage->isReplyable()) { + return new DataResponse(['error' => 'reply-to'], Http::STATUS_BAD_REQUEST); + } + } + + if ($threadId !== 0 && !$this->threadService->validateThread($this->room->getId(), $threadId)) { + return new DataResponse(['error' => 'reply-to'], Http::STATUS_BAD_REQUEST); + } + + $sendAtDateTime = $this->timeFactory->getDateTime('@' . $sendAt, new \DateTimeZone('UTC')); + try { + $createThread = $replyTo === 0 && $threadId === Thread::THREAD_NONE && $threadTitle !== ''; + $threadId = $createThread ? Thread::THREAD_CREATE : $threadId; + $scheduledMessage = $this->scheduledMessageManager->scheduleMessage( + $this->room, + $this->participant, + $message, + ChatManager::VERB_MESSAGE, + $parent, + $threadId, + $sendAtDateTime, + [ + ScheduledMessage::METADATA_THREAD_TITLE => $threadTitle, + ScheduledMessage::METADATA_SILENT => $silent, + ScheduledMessage::METADATA_THREAD_ID => $threadId, + ] + ); + $this->participantService->setHasScheduledMessages($this->participant, true); + } catch (MessageTooLongException) { + return new DataResponse(['error' => 'message'], Http::STATUS_REQUEST_ENTITY_TOO_LARGE); + } + + $data = $this->scheduledMessageManager->parseScheduledMessage($scheduledMessage, $parentMessage); + return new DataResponse($data, Http::STATUS_CREATED); + } + + /** + * Update a scheduled message + * + * Required capability: `scheduled-messages` + * + * @param int $messageId The scheduled message id + * @param string $message The scheduled message to send + * @param int $sendAt When to send the scheduled message + * @param bool $silent If sent silent the scheduled message will not create any notifications + * @param string $threadTitle The thread title if scheduled message is creating a thread + * @return DataResponse|DataResponse|DataResponse|DataResponse + * + * 202: Message updated successfully + * 400: Editing scheduled message is not possible + * 404: Actor not found + * 413: Message too long + */ + #[NoAdminRequired] + #[RequireModeratorOrNoLobby] + #[RequireLoggedInParticipant] + #[RequirePermission(permission: RequirePermission::CHAT)] + #[RequireReadWriteConversation] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/chat/{token}/schedule/{messageId}', requirements: [ + 'apiVersion' => '(v1)', + 'token' => '[a-z0-9]{4,30}', + 'messageId' => '[0-9]{4,30}', + ])] + public function editScheduledMessage( + int $messageId, + string $message, + int $sendAt, + bool $silent = false, + string $threadTitle = '', + ): DataResponse { + if ($this->participant->isSelfJoinedOrGuest()) { + return new DataResponse(['error' => 'actor'], Http::STATUS_NOT_FOUND); + } + + if ($sendAt <= $this->timeFactory->getTime()) { + return new DataResponse(['error' => 'send-at'], Http::STATUS_BAD_REQUEST); + } + + if (trim($message) === '') { + return new DataResponse(['error' => 'message'], Http::STATUS_BAD_REQUEST); + } + + $sendAtDateTime = $this->timeFactory->getDateTime('@' . $sendAt, new \DateTimeZone('UTC')); + try { + $scheduledMessage = $this->scheduledMessageManager->editMessage( + $this->room, + $messageId, + $this->participant, + $message, + $silent, + $sendAtDateTime, + $threadTitle + ); + $this->participantService->setHasScheduledMessages($this->participant, true); + } catch (MessageTooLongException) { + return new DataResponse(['error' => 'message'], Http::STATUS_REQUEST_ENTITY_TOO_LARGE); + } catch (\InvalidArgumentException) { + return new DataResponse(['error' => 'thread-title'], Http::STATUS_BAD_REQUEST); + } catch (DoesNotExistException) { + return new DataResponse(['error' => 'message'], Http::STATUS_NOT_FOUND); + } + + $parentMessage = null; + if ($scheduledMessage->getParentId() !== null) { + try { + $parent = $this->chatManager->getParentComment($this->room, (string)$scheduledMessage->getParentId()); + $parentMessage = $this->messageParser->createMessage($this->room, $this->participant, $parent, $this->l); + $this->messageParser->parseMessage($parentMessage); + } catch (NotFoundException) { + } + } + + $data = $this->scheduledMessageManager->parseScheduledMessage($scheduledMessage, $parentMessage); + return new DataResponse($data, Http::STATUS_ACCEPTED); + } + + /** + * Delete a scheduled message + * + * Required capability: `scheduled-messages` + * + * @param int $messageId The scheduled message ud + * @return DataResponse|DataResponse + * + * 200: Message deleted + * 404: Message not found + */ + #[NoAdminRequired] + #[RequireModeratorOrNoLobby] + #[RequireLoggedInParticipant] + #[RequirePermission(permission: RequirePermission::CHAT)] + #[RequireReadWriteConversation] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/chat/{token}/schedule/{messageId}', requirements: [ + 'apiVersion' => '(v1)', + 'token' => '[a-z0-9]{4,30}', + 'messageId' => '[0-9]{4,30}', + ])] + public function deleteScheduleMessage(int $messageId): DataResponse { + if ($this->participant->isSelfJoinedOrGuest()) { + return new DataResponse(['error' => 'actor'], Http::STATUS_NOT_FOUND); + } + + $deleted = $this->scheduledMessageManager->deleteMessage( + $this->room, + $messageId, + $this->participant, + ); + + if ($deleted === 0) { + return new DataResponse(['error' => 'message'], Http::STATUS_NOT_FOUND); + } + + $hasScheduledMessages = $this->scheduledMessageManager->getScheduledMessageCount($this->room, $this->participant) > 0; + $this->participantService->setHasScheduledMessages($this->participant, $hasScheduledMessages); + return new DataResponse([], Http::STATUS_OK); + } + /** * Sends a rich-object to the given room * diff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php index ef0036338f..c7d7674065 100644 --- a/lib/Controller/RoomController.php +++ b/lib/Controller/RoomController.php @@ -8,7 +8,6 @@ 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; @@ -503,7 +502,7 @@ class RoomController extends AEnvironmentAwareOCSController { $statuses = $this->statusManager->getUserStatuses($userIds); } return new DataResponse($this->formatRoom($room, $participant, $statuses, $isSIPBridgeRequest), Http::STATUS_OK, $this->getTalkHashHeader()); - } catch (RoomNotFoundException $e) { + } catch (RoomNotFoundException) { /** * A hack to fix type collision * @var DataResponse $response diff --git a/lib/Listener/UserDeletedListener.php b/lib/Listener/UserDeletedListener.php index 7dbf4a1fca..68e3a3ef96 100644 --- a/lib/Listener/UserDeletedListener.php +++ b/lib/Listener/UserDeletedListener.php @@ -12,6 +12,7 @@ use OCA\Talk\Manager; use OCA\Talk\Model\Attendee; use OCA\Talk\Service\ConsentService; use OCA\Talk\Service\PollService; +use OCA\Talk\Service\ScheduledMessageService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\User\Events\UserDeletedEvent; @@ -25,6 +26,7 @@ class UserDeletedListener implements IEventListener { private Manager $manager, private PollService $pollService, private ConsentService $consentService, + private ScheduledMessageService $messageManager, ) { } @@ -41,5 +43,7 @@ class UserDeletedListener implements IEventListener { $this->pollService->neutralizeDeletedUser(Attendee::ACTOR_USERS, $user->getUID()); $this->consentService->deleteByActor(Attendee::ACTOR_USERS, $user->getUID()); + + $this->messageManager->deleteByActor(Attendee::ACTOR_USERS, $user->getUID()); } } diff --git a/lib/Migration/Version23000Date20251105125333.php b/lib/Migration/Version23000Date20251105125333.php new file mode 100644 index 0000000000..8eb9d9114c --- /dev/null +++ b/lib/Migration/Version23000Date20251105125333.php @@ -0,0 +1,91 @@ +hasTable('talk_scheduled_msg')) { + $table = $schema->createTable('talk_scheduled_msg'); + $table->addColumn('id', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + 'length' => 20 + ]); + $table->addColumn('room_id', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('actor_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('actor_type', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('message', Types::TEXT, [ + 'notnull' => false, + 'default' => '', + ]); + $table->addColumn('message_type', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('meta_data', Types::TEXT, [ + 'notnull' => false, + ]); + $table->addColumn('thread_id', Types::BIGINT, [ + 'notnull' => true, + 'default' => 0, + ]); + $table->addColumn('parent_id', Types::BIGINT, [ + 'notnull' => false, + 'default' => null, + ]); + $table->addColumn('created_at', Types::DATETIME, [ + 'notnull' => false, + 'default' => null, + ]); + $table->addColumn('send_at', Types::DATETIME, [ + 'notnull' => false, + 'default' => null, + ]); + $table->setPrimaryKey(['id']); + $table->addIndex(['room_id'], 'tt_room_sched'); + $table->addIndex(['room_id', 'actor_type', 'actor_id'], 'tt_actor_room_sched'); + $table->addIndex(['send_at'], 'tt_send_at_sched'); + } + + $table = $schema->getTable('talk_attendees'); + if (!$table->hasColumn('has_scheduled_messages')) { + $table->addColumn('has_scheduled_messages', Types::BOOLEAN, [ + 'notnull' => true, + 'default' => false, + ]); + } + + return $schema; + } +} diff --git a/lib/Model/Attendee.php b/lib/Model/Attendee.php index caf34e5e57..3e4b0cc038 100644 --- a/lib/Model/Attendee.php +++ b/lib/Model/Attendee.php @@ -72,6 +72,8 @@ use OCP\DB\Types; * @method bool getHasUnreadThreadDirects() * @method void setHiddenPinnedId(int $hiddenPinnedId) * @method int getHiddenPinnedId() + * @method void setHasScheduledMessages(bool $scheduledMessages) + * @method bool getHasScheduledMessages() */ class Attendee extends Entity { public const ACTOR_USERS = 'users'; @@ -145,6 +147,7 @@ class Attendee extends Entity { protected bool $hasUnreadThreadMentions = false; protected bool $hasUnreadThreadDirects = false; protected int $hiddenPinnedId = 0; + protected bool $hasScheduledMessages = false; public function __construct() { $this->addType('roomId', Types::BIGINT); @@ -177,6 +180,8 @@ class Attendee extends Entity { $this->addType('hasUnreadThreadMentions', Types::BOOLEAN); $this->addType('hasUnreadThreadDirects', Types::BOOLEAN); $this->addType('hiddenPinnedId', Types::BIGINT); + $this->addType('hasScheduledMessages', Types::BOOLEAN); + } public function getDisplayName(): string { diff --git a/lib/Model/Message.php b/lib/Model/Message.php index a27bade533..2a7a1112a7 100644 --- a/lib/Model/Message.php +++ b/lib/Model/Message.php @@ -25,6 +25,7 @@ class Message { public const METADATA_SILENT = 'silent'; public const METADATA_CAN_MENTION_ALL = 'can_mention_all'; public const METADATA_THREAD_ID = 'thread_id'; + public const METADATA_THREAD_TITLE = 'thread_title'; public const METADATA_PINNED_BY_TYPE = 'pinned_by_type'; public const METADATA_PINNED_BY_ID = 'pinned_by_id'; public const METADATA_PINNED_BY_NAME = 'pinned_by_name'; diff --git a/lib/Model/ScheduledMessage.php b/lib/Model/ScheduledMessage.php new file mode 100644 index 0000000000..d4398d47ee --- /dev/null +++ b/lib/Model/ScheduledMessage.php @@ -0,0 +1,145 @@ +addType('room_id', Types::BIGINT); + $this->addType('actorId', Types::STRING); + $this->addType('actorType', Types::STRING); + $this->addType('threadId', Types::BIGINT); + $this->addType('parentId', Types::BIGINT); + $this->addType('message', Types::TEXT); + $this->addType('messageType', Types::STRING); + $this->addType('metaData', Types::TEXT); + $this->addType('sendAt', Types::DATETIME); + $this->addType('createdAt', Types::DATETIME); + } + + /** + * @return TalkScheduledMessageMetaData + */ + public function getDecodedMetaData(): array { + return json_decode($this->metaData, true, 512, JSON_THROW_ON_ERROR); + } + + public function setMetaData(?array $metaData): void { + $this->metaData = json_encode($metaData, JSON_THROW_ON_ERROR); + $this->markFieldUpdated('metaData'); + } + + /** + * @throws MessageTooLongException When the message is too long (~32k characters) + */ + public function setMessage(string $message): void { + $message = trim($message); + if (mb_strlen($message, 'UTF-8') > ChatManager::MAX_CHAT_LENGTH) { + throw new MessageTooLongException('Comment message must not exceed ' . ChatManager::MAX_CHAT_LENGTH . ' characters'); + } + $this->message = $message; + $this->markFieldUpdated('message'); + } + + #[\Override] + public function jsonSerialize(): array { + return [ + 'roomId' => $this->getRoomId(), + 'actorId' => $this->getActorId(), + 'actorType' => $this->getActorType(), + 'threadId' => $this->getThreadId(), + 'parentId' => $this->getParentId(), + 'message' => $this->getMessage(), + 'messageType' => $this->getMessageType(), + 'createdAt' => $this->getCreatedAt()->getTimestamp(), + 'sendAt' => $this->getSendAt()?->getTimestamp(), + 'metaData' => $this->getDecodedMetaData(), + ]; + } + + /** + * @return TalkScheduledMessage + */ + public function toArray(?Message $parent, ?Thread $thread) : array { + $data = [ + 'id' => $this->id, + 'roomId' => $this->getRoomId(), + 'actorId' => $this->getActorId(), + 'actorType' => $this->getActorType(), + 'threadId' => $this->getThreadId(), + 'parentId' => $this->getParentId(), + 'message' => $this->getMessage(), + 'messageType' => $this->getMessageType(), + 'createdAt' => $this->getCreatedAt()->getTimestamp(), + 'sendAt' => $this->getSendAt()?->getTimestamp(), + ]; + + if ($parent !== null) { + $data['parent'] = $parent->toArray('json', $thread); + } + + $metaData = $this->getDecodedMetaData(); + if ($thread !== null) { + $data['threadExists'] = true; + $data['threadTitle'] = $thread->getName(); + $metaData[self::METADATA_THREAD_TITLE] = $thread->getName(); + } elseif (isset($metaData[self::METADATA_THREAD_TITLE]) && $this->getThreadId() === Thread::THREAD_CREATE) { + $data['threadExists'] = false; + $data['threadTitle'] = (string)$metaData[self::METADATA_THREAD_TITLE]; + } + $data['metaData'] = $metaData; + return $data; + } +} diff --git a/lib/Model/ScheduledMessageMapper.php b/lib/Model/ScheduledMessageMapper.php new file mode 100644 index 0000000000..ecf73b1cee --- /dev/null +++ b/lib/Model/ScheduledMessageMapper.php @@ -0,0 +1,137 @@ + findEntities(IQueryBuilder $query) + * @method ScheduledMessage update(ScheduledMessage $scheduledMessage) + * @method ScheduledMessage delete(ScheduledMessage $scheduledMessage) + * @template-extends QBMapper + */ +class ScheduledMessageMapper extends QBMapper { + public function __construct( + IDBConnection $db, + protected IGenerator $generator, + ) { + parent::__construct($db, 'talk_scheduled_msg', ScheduledMessage::class); + } + + /** + * @throws DoesNotExistException + */ + public function findById(Room $chat, int $id, string $actorType, string $actorId): ScheduledMessage { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from($this->getTableName()) + ->where($query->expr()->eq('id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq('actor_type', $query->createNamedParameter($actorType, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('actor_id', $query->createNamedParameter($actorId, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('room_id', $query->createNamedParameter($chat->getId(), IQueryBuilder::PARAM_STR))); + + return $this->findEntity($query); + } + + public function findByRoomAndActor(Room $chat, string $actorType, string $actorId): array { + $query = $this->db->getQueryBuilder(); + $query->select('s.*'); + $helper = new SelectHelper(); + $helper->selectThreadsTable($query, aliasAll: true); + $query->from($this->getTableName(), 's') + ->where($query->expr()->eq('s.room_id', $query->createNamedParameter($chat->getId(), IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('s.actor_type', $query->createNamedParameter($actorType, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('s.actor_id', $query->createNamedParameter($actorId, IQueryBuilder::PARAM_STR))) + ->leftJoin('s', 'talk_threads', 'th', $query->expr()->eq('s.thread_id', 'th.id')) + ->orderBy('s.send_at', 'ASC'); + + $cursor = $query->executeQuery(); + $result = $cursor->fetchAll(); + $cursor->closeCursor(); + + return $result; + } + + public function getCountByActorAndRoom(Room $chat, string $actorType, string $actorId): int { + $query = $this->db->getQueryBuilder(); + $query->select(['room_id', $query->func()->count('*')]) + ->from($this->getTableName()) + ->where($query->expr()->eq('actor_type', $query->createNamedParameter($actorType, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('actor_id', $query->createNamedParameter($actorId, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('room_id', $query->createNamedParameter($chat->getId(), IQueryBuilder::PARAM_STR))) + ->groupBy('room_id'); + + $result = $query->executeQuery(); + $count = $result->rowCount(); + $result->closeCursor(); + + return $count; + } + + public function deleteMessagesByRoomAndActor(Room $chat, string $actorType, string $actorId): int { + $query = $this->db->getQueryBuilder(); + $query->delete($this->getTableName()) + ->where($query->expr()->eq('room_id', $query->createNamedParameter($chat->getId(), IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('actor_type', $query->createNamedParameter($actorType, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('actor_id', $query->createNamedParameter($actorId, IQueryBuilder::PARAM_STR))); + + return $query->executeStatement(); + } + + public function deleteMessagesByRoom(Room $chat): int { + $query = $this->db->getQueryBuilder(); + $query->delete($this->getTableName()) + ->where($query->expr()->eq('room_id', $query->createNamedParameter($chat->getId(), IQueryBuilder::PARAM_STR))); + return $query->executeStatement(); + } + + public function deleteById(Room $chat, int $id, string $actorType, string $actorId): int { + $query = $this->db->getQueryBuilder(); + $query->delete($this->getTableName()) + ->where($query->expr()->eq('room_id', $query->createNamedParameter($chat->getId(), IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('id', $query->createNamedParameter($id, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('actor_type', $query->createNamedParameter($actorType, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('actor_id', $query->createNamedParameter($actorId, IQueryBuilder::PARAM_STR))); + + return $query->executeStatement(); + } + + public function deleteByActor(string $actorType, string $actorId): int { + $query = $this->db->getQueryBuilder(); + $query->delete($this->getTableName()) + ->where($query->expr()->eq('actor_type', $query->createNamedParameter($actorType, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->eq('actor_id', $query->createNamedParameter($actorId, IQueryBuilder::PARAM_STR))); + + return $query->executeStatement(); + } + + public function getMessagesDue(\DateTime $dateTime): array { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from($this->getTableName()) + ->where($query->expr()->lt('send_at', $query->createNamedParameter($dateTime, IQueryBuilder::PARAM_DATETIME_MUTABLE))); + + return $this->findEntities($query); + } + + #[\Override] + public function insert(Entity $entity): Entity { + /** @psalm-suppress InvalidArgument */ + $entity->setId($this->generator->nextId()); + return parent::insert($entity); + } +} diff --git a/lib/Model/SelectHelper.php b/lib/Model/SelectHelper.php index db6d97f977..ea0b512cd8 100644 --- a/lib/Model/SelectHelper.php +++ b/lib/Model/SelectHelper.php @@ -50,6 +50,33 @@ class SelectHelper { ])->selectAlias($alias . 'id', 'r_id'); } + public function selectThreadsTable(IQueryBuilder $query, string $alias = 'th', bool $aliasAll = false): void { + if ($alias !== '') { + $alias .= '.'; + } + + if ($aliasAll) { + $query + ->selectAlias($alias . 'room_id', 'th_room_id') + ->selectAlias($alias . 'last_message_id', 'th_last_message_id') + ->selectAlias($alias . 'num_replies', 'th_num_replies') + ->selectAlias($alias . 'last_activity', 'th_last_activity') + ->selectAlias($alias . 'name', 'th_name') + ->selectAlias($alias . 'id', 'th_id'); + return; + } + + $query->addSelect([ + $alias . 'room_id', + $alias . 'last_message_id', + $alias . 'num_replies', + $alias . 'last_activity', + $alias . 'name', + ])->selectAlias($alias . 'id', 'th_id'); + + + } + public function selectAttendeesTable(IQueryBuilder $query, string $alias = 'a'): void { if ($alias !== '') { $alias .= '.'; diff --git a/lib/Participant.php b/lib/Participant.php index f56495491b..b5336cd893 100644 --- a/lib/Participant.php +++ b/lib/Participant.php @@ -66,6 +66,15 @@ class Participant { return \in_array($participantType, [self::GUEST, self::GUEST_MODERATOR], true); } + public function isSelfJoinedOrGuest(): bool { + $participantType = $this->attendee->getParticipantType(); + return \in_array($participantType, [self::GUEST, self::GUEST_MODERATOR, self::USER_SELF_JOINED], true); + } + + public function getHasScheduledMessages(): bool { + return $this->attendee->getHasScheduledMessages(); + } + public function hasModeratorPermissions(bool $guestModeratorAllowed = true): bool { $participantType = $this->attendee->getParticipantType(); if (!$guestModeratorAllowed) { diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index f8c8028475..2318442d5f 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -387,6 +387,8 @@ namespace OCA\Talk; * lastPinnedId: int, * // Required capability: `pinned-messages` * hiddenPinnedId: int, + * // Required capability: `scheduled-messages` (local) + * hasScheduledMessages: bool, * } * * @psalm-type TalkDashboardEventAttachment = array{ @@ -563,6 +565,30 @@ namespace OCA\Talk; * rtl: bool, * }, * } + * + * @psalm-type TalkScheduledMessageMetaData = array{ + * threadId: int, + * threadTitle: string, + * silent: bool, + * lastEditedTime?: int, + * } + * + * @psalm-type TalkScheduledMessage = array{ + * id: int, + * roomId: int, + * actorId: string, + * actorType: string, + * threadId: int, + * threadExists?: boolean, + * threadTitle?: string, + * parentId: ?int, + * parent?: TalkChatMessage, + * message: string, + * messageType: string, + * createdAt: int, + * sendAt: ?int, + * metaData: TalkScheduledMessageMetaData, + * } */ class ResponseDefinitions { } diff --git a/lib/Service/ParticipantService.php b/lib/Service/ParticipantService.php index 50df51fa03..a190a290cc 100644 --- a/lib/Service/ParticipantService.php +++ b/lib/Service/ParticipantService.php @@ -2381,4 +2381,10 @@ class ParticipantService { $this->cacheParticipant($room, $participant); return $participant; } + + public function setHasScheduledMessages(Participant $participant, bool $hasScheduledMessages): void { + $attendee = $participant->getAttendee(); + $attendee->setHasScheduledMessages($hasScheduledMessages); + $this->attendeeMapper->update($attendee); + } } diff --git a/lib/Service/RoomFormatter.php b/lib/Service/RoomFormatter.php index 1868e9c826..ba709b9cda 100644 --- a/lib/Service/RoomFormatter.php +++ b/lib/Service/RoomFormatter.php @@ -158,6 +158,7 @@ class RoomFormatter { 'isArchived' => false, 'isImportant' => false, 'isSensitive' => false, + 'hasScheduledMessages' => false, ]; if ($room->isFederatedConversation()) { @@ -345,6 +346,7 @@ class RoomFormatter { && ($room->getType() === Room::TYPE_GROUP || $room->getType() === Room::TYPE_PUBLIC) && $currentParticipant->hasModeratorPermissions(false) && $this->talkConfig->canUserEnableSIP($currentUser); + $roomData['hasScheduledMessages'] = $currentParticipant->getHasScheduledMessages(); } } elseif ($attendee->getActorType() === Attendee::ACTOR_FEDERATED_USERS) { $lastReadMessage = $attendee->getLastReadMessage(); diff --git a/lib/Service/ScheduledMessageService.php b/lib/Service/ScheduledMessageService.php new file mode 100644 index 0000000000..eb09427788 --- /dev/null +++ b/lib/Service/ScheduledMessageService.php @@ -0,0 +1,200 @@ +setRoomId($chat->getId()); + $scheduledMessage->setActorId($participant->getAttendee()->getActorId()); + $scheduledMessage->setActorType($participant->getAttendee()->getActorType()); + $scheduledMessage->setSendAt($sendAt); + $scheduledMessage->setMessage($message); + $scheduledMessage->setMessageType($messageType); + if ($parent instanceof IComment) { + $scheduledMessage->setParentId((int)$parent->getId()); + } + $scheduledMessage->setThreadId($threadId); + $scheduledMessage->setMetaData($metadata); + $scheduledMessage->setCreatedAt($this->timeFactory->getDateTime()); + + $this->scheduledMessageMapper->insert($scheduledMessage); + + return $scheduledMessage; + } + + public function deleteMessage(Room $chat, int $id, Participant $participant): int { + return $this->scheduledMessageMapper->deleteById( + $chat, + $id, + $participant->getAttendee()->getActorType(), + $participant->getAttendee()->getActorId() + ); + } + + /** + * @throws DoesNotExistException + * @throws MessageTooLongException + * @throws \InvalidArgumentException + */ + public function editMessage( + Room $chat, + int $id, + Participant $participant, + string $text, + bool $isSilent, + \DateTime $sendAt, + string $threadTitle = '', + ): ScheduledMessage { + $message = $this->scheduledMessageMapper->findById( + $chat, + $id, + $participant->getAttendee()->getActorType(), + $participant->getAttendee()->getActorId() + ); + + $metaData = $message->getDecodedMetaData(); + if ($metaData[ScheduledMessage::METADATA_THREAD_ID] !== Thread::THREAD_CREATE && $threadTitle !== '') { + throw new \InvalidArgumentException('thread-title'); + } + + if ($metaData[ScheduledMessage::METADATA_THREAD_ID] === Thread::THREAD_CREATE && $threadTitle !== '') { + $metaData[ScheduledMessage::METADATA_THREAD_TITLE] = $threadTitle; + } + + $metaData[ScheduledMessage::METADATA_LAST_EDITED_TIME] = $this->timeFactory->getTime(); + $metaData[ScheduledMessage::METADATA_SILENT] = $isSilent; + $message->setMetaData($metaData); + $message->setMessage($text); + $message->setSendAt($sendAt); + $this->scheduledMessageMapper->update($message); + + return $message; + } + + public function deleteByActor(string $actorType, string $actorId): void { + $this->scheduledMessageMapper->deleteByActor($actorType, $actorId); + } + + /** + * @return list + */ + public function getMessages(Room $chat, Participant $participant): array { + $result = $this->scheduledMessageMapper->findByRoomAndActor( + $chat, + $participant->getAttendee()->getActorType(), + $participant->getAttendee()->getActorId() + ); + + $commentIds = array_filter(array_map(static function (array $result) { + return $result['parent_id']; + }, $result)); + try { + $comments = $this->commentsManager->getCommentsById($commentIds); + } catch (Exception) { + $comments = []; + } + + $messages = []; + foreach ($result as $row) { + $parent = $thread = null; + $entity = []; + foreach ($row as $field => $value) { + if (str_starts_with($field, 'th_')) { + $thread[substr($field, 3)] = $value; + continue; + } + $entity[$field] = $value; + } + + $scheduleMessage = ScheduledMessage::fromRow($entity); + if ($entity['parent_id'] !== null && isset($comments[$entity['parent_id']])) { + $parent = $this->messageParser->createMessage($chat, $participant, $comments[$entity['parent_id']], $this->l); + $this->messageParser->parseMessage($parent); + } + if (in_array($thread['id'], [null, Thread::THREAD_NONE, Thread::THREAD_CREATE], true)) { + $thread = null; + } else { + $thread = Thread::fromRow($thread); + } + $messages[] = $this->parseScheduledMessage($scheduleMessage, $parent, $thread); + } + + return $messages; + } + + public function parseScheduledMessage(ScheduledMessage $message, ?Message $parentMessage, ?Thread $thread = null): array { + if ($thread === null + && $message->getThreadId() !== Thread::THREAD_NONE + && $message->getThreadId() !== Thread::THREAD_CREATE + ) { + try { + $thread = $this->threadService->findByThreadId( + $message->getRoomId(), + $message->getThreadId(), + ); + } catch (DoesNotExistException $e) { + $this->logger->warning('Could not find thread ' . (string)$message->getThreadId() . ' for scheduled message', ['exception' => $e]); + $thread = null; + } + } + return $message->toArray($parentMessage, $thread ?? null); + } + + public function getScheduledMessageCount(Room $chat, Participant $participant): int { + return $this->scheduledMessageMapper->getCountByActorAndRoom( + $chat, + $participant->getAttendee()->getActorType(), + $participant->getAttendee()->getActorId(), + ); + } +} diff --git a/openapi-backend-sipbridge.json b/openapi-backend-sipbridge.json index 60325ee754..a2b42977e1 100644 --- a/openapi-backend-sipbridge.json +++ b/openapi-backend-sipbridge.json @@ -700,7 +700,8 @@ "isImportant", "isSensitive", "lastPinnedId", - "hiddenPinnedId" + "hiddenPinnedId", + "hasScheduledMessages" ], "properties": { "actorId": { @@ -1003,6 +1004,10 @@ "type": "integer", "format": "int64", "description": "Required capability: `pinned-messages`" + }, + "hasScheduledMessages": { + "type": "boolean", + "description": "Required capability: `scheduled-messages` (local)" } } }, diff --git a/openapi-federation.json b/openapi-federation.json index ff4a4a6999..9ce93b86cf 100644 --- a/openapi-federation.json +++ b/openapi-federation.json @@ -754,7 +754,8 @@ "isImportant", "isSensitive", "lastPinnedId", - "hiddenPinnedId" + "hiddenPinnedId", + "hasScheduledMessages" ], "properties": { "actorId": { @@ -1057,6 +1058,10 @@ "type": "integer", "format": "int64", "description": "Required capability: `pinned-messages`" + }, + "hasScheduledMessages": { + "type": "boolean", + "description": "Required capability: `scheduled-messages` (local)" } } }, diff --git a/openapi-full.json b/openapi-full.json index 48dd07be52..8337c07660 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -1578,7 +1578,8 @@ "isImportant", "isSensitive", "lastPinnedId", - "hiddenPinnedId" + "hiddenPinnedId", + "hasScheduledMessages" ], "properties": { "actorId": { @@ -1881,6 +1882,10 @@ "type": "integer", "format": "int64", "description": "Required capability: `pinned-messages`" + }, + "hasScheduledMessages": { + "type": "boolean", + "description": "Required capability: `scheduled-messages` (local)" } } }, @@ -1912,6 +1917,98 @@ } ] }, + "ScheduledMessage": { + "type": "object", + "required": [ + "id", + "roomId", + "actorId", + "actorType", + "threadId", + "parentId", + "message", + "messageType", + "createdAt", + "sendAt", + "metaData" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "roomId": { + "type": "integer", + "format": "int64" + }, + "actorId": { + "type": "string" + }, + "actorType": { + "type": "string" + }, + "threadId": { + "type": "integer", + "format": "int64" + }, + "threadExists": { + "type": "boolean" + }, + "threadTitle": { + "type": "string" + }, + "parentId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "parent": { + "$ref": "#/components/schemas/ChatMessage" + }, + "message": { + "type": "string" + }, + "messageType": { + "type": "string" + }, + "createdAt": { + "type": "integer", + "format": "int64" + }, + "sendAt": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "metaData": { + "$ref": "#/components/schemas/ScheduledMessageMetaData" + } + } + }, + "ScheduledMessageMetaData": { + "type": "object", + "required": [ + "threadId", + "threadTitle", + "silent" + ], + "properties": { + "threadId": { + "type": "integer", + "format": "int64" + }, + "threadTitle": { + "type": "string" + }, + "silent": { + "type": "boolean" + }, + "lastEditedTime": { + "type": "integer", + "format": "int64" + } + } + }, "SignalingFederationSettings": { "type": "object", "required": [ @@ -7634,6 +7731,926 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/schedule": { + "get": { + "operationId": "chat-scheduled-messages", + "summary": "Get all scheduled nessages of a given room and participant", + "description": "The author and timestamp are automatically set to the current user and time.\nRequired capability: `scheduled-messages`", + "tags": [ + "chat" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "All scheduled messages for this room and participant", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduledMessage" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Could not get scheduled messages", + "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": [ + "message" + ] + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Actor not found", + "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": [ + "actor" + ] + } + } + } + } + } + } + } + } + } + }, + "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": {} + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "chat-schedule-message", + "summary": "Schedules the sending of a new chat message to the given room", + "description": "The author and timestamp are automatically set to the current user and time.\nRequired capability: `scheduled-messages`", + "tags": [ + "chat" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message", + "sendAt" + ], + "properties": { + "message": { + "type": "string", + "description": "The message to send" + }, + "sendAt": { + "type": "integer", + "format": "int64", + "description": "When to send the scheduled message" + }, + "replyTo": { + "type": "integer", + "format": "int64", + "default": 0, + "description": "Parent id which this scheduled message is a reply to", + "minimum": 0 + }, + "silent": { + "type": "boolean", + "default": false, + "description": "If sent silent the scheduled message will not create any notifications when sent" + }, + "threadTitle": { + "type": "string", + "default": "", + "description": "Only supported when not replying, when given will create a thread (requires `threads` capability)" + }, + "threadId": { + "type": "integer", + "format": "int64", + "default": 0, + "description": "Thread id without quoting a specific message (requires `threads` capability)" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "Message scheduled successfully", + "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/ScheduledMessage" + } + } + } + } + } + } + } + }, + "400": { + "description": "Scheduling the message is not possible", + "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": [ + "message", + "reply-to", + "send-at" + ] + } + } + } + } + } + } + } + } + } + }, + "413": { + "description": "Message too long", + "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": [ + "message" + ] + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Actor not found", + "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": [ + "actor" + ] + } + } + } + } + } + } + } + } + } + }, + "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}/chat/{token}/schedule/{messageId}": { + "post": { + "operationId": "chat-edit-scheduled-message", + "summary": "Update a scheduled message", + "description": "Required capability: `scheduled-messages`", + "tags": [ + "chat" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message", + "sendAt" + ], + "properties": { + "message": { + "type": "string", + "description": "The scheduled message to send" + }, + "sendAt": { + "type": "integer", + "format": "int64", + "description": "When to send the scheduled message" + }, + "silent": { + "type": "boolean", + "default": false, + "description": "If sent silent the scheduled message will not create any notifications" + }, + "threadTitle": { + "type": "string", + "default": "", + "description": "The thread title if scheduled message is creating a thread" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "messageId", + "in": "path", + "description": "The scheduled message id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "202": { + "description": "Message updated successfully", + "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/ScheduledMessage" + } + } + } + } + } + } + } + }, + "400": { + "description": "Editing scheduled message is not possible", + "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": [ + "message", + "send-at", + "thread-title" + ] + } + } + } + } + } + } + } + } + } + }, + "413": { + "description": "Message too long", + "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": [ + "message" + ] + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Actor not found", + "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": [ + "actor" + ] + } + } + } + } + } + } + } + } + } + }, + "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": "chat-delete-schedule-message", + "summary": "Delete a scheduled message", + "description": "Required capability: `scheduled-messages`", + "tags": [ + "chat" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "messageId", + "in": "path", + "description": "The scheduled message ud", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "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": "Message deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object" + } + } + } + } + } + } + } + }, + "404": { + "description": "Message not found", + "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": [ + "actor", + "message" + ] + } + } + } + } + } + } + } + } + } + }, + "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}/chat/{token}/share": { "post": { "operationId": "chat-share-object-to-chat", diff --git a/openapi.json b/openapi.json index de72365fd1..6208978905 100644 --- a/openapi.json +++ b/openapi.json @@ -1483,7 +1483,8 @@ "isImportant", "isSensitive", "lastPinnedId", - "hiddenPinnedId" + "hiddenPinnedId", + "hasScheduledMessages" ], "properties": { "actorId": { @@ -1786,6 +1787,10 @@ "type": "integer", "format": "int64", "description": "Required capability: `pinned-messages`" + }, + "hasScheduledMessages": { + "type": "boolean", + "description": "Required capability: `scheduled-messages` (local)" } } }, @@ -1817,6 +1822,98 @@ } ] }, + "ScheduledMessage": { + "type": "object", + "required": [ + "id", + "roomId", + "actorId", + "actorType", + "threadId", + "parentId", + "message", + "messageType", + "createdAt", + "sendAt", + "metaData" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "roomId": { + "type": "integer", + "format": "int64" + }, + "actorId": { + "type": "string" + }, + "actorType": { + "type": "string" + }, + "threadId": { + "type": "integer", + "format": "int64" + }, + "threadExists": { + "type": "boolean" + }, + "threadTitle": { + "type": "string" + }, + "parentId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "parent": { + "$ref": "#/components/schemas/ChatMessage" + }, + "message": { + "type": "string" + }, + "messageType": { + "type": "string" + }, + "createdAt": { + "type": "integer", + "format": "int64" + }, + "sendAt": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "metaData": { + "$ref": "#/components/schemas/ScheduledMessageMetaData" + } + } + }, + "ScheduledMessageMetaData": { + "type": "object", + "required": [ + "threadId", + "threadTitle", + "silent" + ], + "properties": { + "threadId": { + "type": "integer", + "format": "int64" + }, + "threadTitle": { + "type": "string" + }, + "silent": { + "type": "boolean" + }, + "lastEditedTime": { + "type": "integer", + "format": "int64" + } + } + }, "SignalingFederationSettings": { "type": "object", "required": [ @@ -7539,6 +7636,926 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/schedule": { + "get": { + "operationId": "chat-scheduled-messages", + "summary": "Get all scheduled nessages of a given room and participant", + "description": "The author and timestamp are automatically set to the current user and time.\nRequired capability: `scheduled-messages`", + "tags": [ + "chat" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "All scheduled messages for this room and participant", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduledMessage" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Could not get scheduled messages", + "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": [ + "message" + ] + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Actor not found", + "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": [ + "actor" + ] + } + } + } + } + } + } + } + } + } + }, + "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": {} + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "chat-schedule-message", + "summary": "Schedules the sending of a new chat message to the given room", + "description": "The author and timestamp are automatically set to the current user and time.\nRequired capability: `scheduled-messages`", + "tags": [ + "chat" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message", + "sendAt" + ], + "properties": { + "message": { + "type": "string", + "description": "The message to send" + }, + "sendAt": { + "type": "integer", + "format": "int64", + "description": "When to send the scheduled message" + }, + "replyTo": { + "type": "integer", + "format": "int64", + "default": 0, + "description": "Parent id which this scheduled message is a reply to", + "minimum": 0 + }, + "silent": { + "type": "boolean", + "default": false, + "description": "If sent silent the scheduled message will not create any notifications when sent" + }, + "threadTitle": { + "type": "string", + "default": "", + "description": "Only supported when not replying, when given will create a thread (requires `threads` capability)" + }, + "threadId": { + "type": "integer", + "format": "int64", + "default": 0, + "description": "Thread id without quoting a specific message (requires `threads` capability)" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "Message scheduled successfully", + "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/ScheduledMessage" + } + } + } + } + } + } + } + }, + "400": { + "description": "Scheduling the message is not possible", + "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": [ + "message", + "reply-to", + "send-at" + ] + } + } + } + } + } + } + } + } + } + }, + "413": { + "description": "Message too long", + "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": [ + "message" + ] + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Actor not found", + "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": [ + "actor" + ] + } + } + } + } + } + } + } + } + } + }, + "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}/chat/{token}/schedule/{messageId}": { + "post": { + "operationId": "chat-edit-scheduled-message", + "summary": "Update a scheduled message", + "description": "Required capability: `scheduled-messages`", + "tags": [ + "chat" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message", + "sendAt" + ], + "properties": { + "message": { + "type": "string", + "description": "The scheduled message to send" + }, + "sendAt": { + "type": "integer", + "format": "int64", + "description": "When to send the scheduled message" + }, + "silent": { + "type": "boolean", + "default": false, + "description": "If sent silent the scheduled message will not create any notifications" + }, + "threadTitle": { + "type": "string", + "default": "", + "description": "The thread title if scheduled message is creating a thread" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "messageId", + "in": "path", + "description": "The scheduled message id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "202": { + "description": "Message updated successfully", + "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/ScheduledMessage" + } + } + } + } + } + } + } + }, + "400": { + "description": "Editing scheduled message is not possible", + "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": [ + "message", + "send-at", + "thread-title" + ] + } + } + } + } + } + } + } + } + } + }, + "413": { + "description": "Message too long", + "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": [ + "message" + ] + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Actor not found", + "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": [ + "actor" + ] + } + } + } + } + } + } + } + } + } + }, + "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": "chat-delete-schedule-message", + "summary": "Delete a scheduled message", + "description": "Required capability: `scheduled-messages`", + "tags": [ + "chat" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "messageId", + "in": "path", + "description": "The scheduled message ud", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "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": "Message deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object" + } + } + } + } + } + } + } + }, + "404": { + "description": "Message not found", + "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": [ + "actor", + "message" + ] + } + } + } + } + } + } + } + } + } + }, + "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}/chat/{token}/share": { "post": { "operationId": "chat-share-object-to-chat", diff --git a/src/types/openapi/openapi-backend-sipbridge.ts b/src/types/openapi/openapi-backend-sipbridge.ts index 9b466d3ba2..1cb25eb3f2 100644 --- a/src/types/openapi/openapi-backend-sipbridge.ts +++ b/src/types/openapi/openapi-backend-sipbridge.ts @@ -542,6 +542,8 @@ export type components = { * @description Required capability: `pinned-messages` */ hiddenPinnedId: number; + /** @description Required capability: `scheduled-messages` (local) */ + hasScheduledMessages: boolean; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; }; diff --git a/src/types/openapi/openapi-federation.ts b/src/types/openapi/openapi-federation.ts index ff302c9885..6ec535cc63 100644 --- a/src/types/openapi/openapi-federation.ts +++ b/src/types/openapi/openapi-federation.ts @@ -569,6 +569,8 @@ export type components = { * @description Required capability: `pinned-messages` */ hiddenPinnedId: number; + /** @description Required capability: `scheduled-messages` (local) */ + hasScheduledMessages: boolean; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; }; diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index cde479c18a..8cdc9dcb2b 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -421,6 +421,56 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/schedule": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get all scheduled nessages of a given room and participant + * @description The author and timestamp are automatically set to the current user and time. + * Required capability: `scheduled-messages` + */ + get: operations["chat-scheduled-messages"]; + put?: never; + /** + * Schedules the sending of a new chat message to the given room + * @description The author and timestamp are automatically set to the current user and time. + * Required capability: `scheduled-messages` + */ + post: operations["chat-schedule-message"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/schedule/{messageId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Update a scheduled message + * @description Required capability: `scheduled-messages` + */ + post: operations["chat-edit-scheduled-message"]; + /** + * Delete a scheduled message + * @description Required capability: `scheduled-messages` + */ + delete: operations["chat-delete-schedule-message"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/share": { parameters: { query?: never; @@ -2952,11 +3002,43 @@ export type components = { * @description Required capability: `pinned-messages` */ hiddenPinnedId: number; + /** @description Required capability: `scheduled-messages` (local) */ + hasScheduledMessages: boolean; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; RoomWithInvalidInvitations: components["schemas"]["Room"] & { invalidParticipants: components["schemas"]["InvitationList"]; }; + ScheduledMessage: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + roomId: number; + actorId: string; + actorType: string; + /** Format: int64 */ + threadId: number; + threadExists?: boolean; + threadTitle?: string; + /** Format: int64 */ + parentId: number | null; + parent?: components["schemas"]["ChatMessage"]; + message: string; + messageType: string; + /** Format: int64 */ + createdAt: number; + /** Format: int64 */ + sendAt: number | null; + metaData: components["schemas"]["ScheduledMessageMetaData"]; + }; + ScheduledMessageMetaData: { + /** Format: int64 */ + threadId: number; + threadTitle: string; + silent: boolean; + /** Format: int64 */ + lastEditedTime?: number; + }; SignalingFederationSettings: { server: string; nextcloudServer: string; @@ -5228,6 +5310,399 @@ export interface operations { }; }; }; + "chat-scheduled-messages": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description All scheduled messages for this room and participant */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ScheduledMessage"][]; + }; + }; + }; + }; + /** @description Could not get scheduled messages */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message"; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Actor not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "actor"; + }; + }; + }; + }; + }; + }; + }; + "chat-schedule-message": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description The message to send */ + message: string; + /** + * Format: int64 + * @description When to send the scheduled message + */ + sendAt: number; + /** + * Format: int64 + * @description Parent id which this scheduled message is a reply to + * @default 0 + */ + replyTo?: number; + /** + * @description If sent silent the scheduled message will not create any notifications when sent + * @default false + */ + silent?: boolean; + /** + * @description Only supported when not replying, when given will create a thread (requires `threads` capability) + * @default + */ + threadTitle?: string; + /** + * Format: int64 + * @description Thread id without quoting a specific message (requires `threads` capability) + * @default 0 + */ + threadId?: number; + }; + }; + }; + responses: { + /** @description Message scheduled successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ScheduledMessage"]; + }; + }; + }; + }; + /** @description Scheduling the message is not possible */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message" | "reply-to" | "send-at"; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Actor not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "actor"; + }; + }; + }; + }; + }; + /** @description Message too long */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message"; + }; + }; + }; + }; + }; + }; + }; + "chat-edit-scheduled-message": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + /** @description The scheduled message id */ + messageId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description The scheduled message to send */ + message: string; + /** + * Format: int64 + * @description When to send the scheduled message + */ + sendAt: number; + /** + * @description If sent silent the scheduled message will not create any notifications + * @default false + */ + silent?: boolean; + /** + * @description The thread title if scheduled message is creating a thread + * @default + */ + threadTitle?: string; + }; + }; + }; + responses: { + /** @description Message updated successfully */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ScheduledMessage"]; + }; + }; + }; + }; + /** @description Editing scheduled message is not possible */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message" | "send-at" | "thread-title"; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Actor not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "actor"; + }; + }; + }; + }; + }; + /** @description Message too long */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message"; + }; + }; + }; + }; + }; + }; + }; + "chat-delete-schedule-message": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + /** @description The scheduled message ud */ + messageId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Message deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: Record; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Message not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "actor" | "message"; + }; + }; + }; + }; + }; + }; + }; "chat-get-objects-shared-in-room": { parameters: { query: { diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 3801d59fc7..916c719ece 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -421,6 +421,56 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/schedule": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get all scheduled nessages of a given room and participant + * @description The author and timestamp are automatically set to the current user and time. + * Required capability: `scheduled-messages` + */ + get: operations["chat-scheduled-messages"]; + put?: never; + /** + * Schedules the sending of a new chat message to the given room + * @description The author and timestamp are automatically set to the current user and time. + * Required capability: `scheduled-messages` + */ + post: operations["chat-schedule-message"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/schedule/{messageId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Update a scheduled message + * @description Required capability: `scheduled-messages` + */ + post: operations["chat-edit-scheduled-message"]; + /** + * Delete a scheduled message + * @description Required capability: `scheduled-messages` + */ + delete: operations["chat-delete-schedule-message"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/share": { parameters: { query?: never; @@ -2414,11 +2464,43 @@ export type components = { * @description Required capability: `pinned-messages` */ hiddenPinnedId: number; + /** @description Required capability: `scheduled-messages` (local) */ + hasScheduledMessages: boolean; }; RoomLastMessage: components["schemas"]["ChatMessage"] | components["schemas"]["ChatProxyMessage"]; RoomWithInvalidInvitations: components["schemas"]["Room"] & { invalidParticipants: components["schemas"]["InvitationList"]; }; + ScheduledMessage: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + roomId: number; + actorId: string; + actorType: string; + /** Format: int64 */ + threadId: number; + threadExists?: boolean; + threadTitle?: string; + /** Format: int64 */ + parentId: number | null; + parent?: components["schemas"]["ChatMessage"]; + message: string; + messageType: string; + /** Format: int64 */ + createdAt: number; + /** Format: int64 */ + sendAt: number | null; + metaData: components["schemas"]["ScheduledMessageMetaData"]; + }; + ScheduledMessageMetaData: { + /** Format: int64 */ + threadId: number; + threadTitle: string; + silent: boolean; + /** Format: int64 */ + lastEditedTime?: number; + }; SignalingFederationSettings: { server: string; nextcloudServer: string; @@ -4690,6 +4772,399 @@ export interface operations { }; }; }; + "chat-scheduled-messages": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description All scheduled messages for this room and participant */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ScheduledMessage"][]; + }; + }; + }; + }; + /** @description Could not get scheduled messages */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message"; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Actor not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "actor"; + }; + }; + }; + }; + }; + }; + }; + "chat-schedule-message": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description The message to send */ + message: string; + /** + * Format: int64 + * @description When to send the scheduled message + */ + sendAt: number; + /** + * Format: int64 + * @description Parent id which this scheduled message is a reply to + * @default 0 + */ + replyTo?: number; + /** + * @description If sent silent the scheduled message will not create any notifications when sent + * @default false + */ + silent?: boolean; + /** + * @description Only supported when not replying, when given will create a thread (requires `threads` capability) + * @default + */ + threadTitle?: string; + /** + * Format: int64 + * @description Thread id without quoting a specific message (requires `threads` capability) + * @default 0 + */ + threadId?: number; + }; + }; + }; + responses: { + /** @description Message scheduled successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ScheduledMessage"]; + }; + }; + }; + }; + /** @description Scheduling the message is not possible */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message" | "reply-to" | "send-at"; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Actor not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "actor"; + }; + }; + }; + }; + }; + /** @description Message too long */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message"; + }; + }; + }; + }; + }; + }; + }; + "chat-edit-scheduled-message": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + /** @description The scheduled message id */ + messageId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description The scheduled message to send */ + message: string; + /** + * Format: int64 + * @description When to send the scheduled message + */ + sendAt: number; + /** + * @description If sent silent the scheduled message will not create any notifications + * @default false + */ + silent?: boolean; + /** + * @description The thread title if scheduled message is creating a thread + * @default + */ + threadTitle?: string; + }; + }; + }; + responses: { + /** @description Message updated successfully */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ScheduledMessage"]; + }; + }; + }; + }; + /** @description Editing scheduled message is not possible */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message" | "send-at" | "thread-title"; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Actor not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "actor"; + }; + }; + }; + }; + }; + /** @description Message too long */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "message"; + }; + }; + }; + }; + }; + }; + }; + "chat-delete-schedule-message": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + /** @description The scheduled message ud */ + messageId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Message deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: Record; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Message not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + error: "actor" | "message"; + }; + }; + }; + }; + }; + }; + }; "chat-get-objects-shared-in-room": { parameters: { query: { diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 84600943ee..78fcd39e72 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -1934,6 +1934,140 @@ class FeatureContext implements Context, SnippetAcceptingContext { Assert::assertEquals(implode("\n", $expected) . "\n", $this->response->getBody()->getContents()); } + #[When('/^user "([^"]*)" schedules a message to room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] + public function userSchedulesMessageToRoom(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { + $row = $formData->getRowsHash(); + $row['sendAt'] = (int)$row['sendAt']; + if (isset($row['replyTo'])) { + $row['replyTo'] = self::$textToMessageId[$row['replyTo']]; + } + if (isset($row['threadId']) && $row['threadId'] !== '0' && $row['threadId'] !== '-1') { + $row['threadId'] = self::$titleToThreadId[$row['threadId']]; + } + + $this->setCurrentUser($user); + $this->sendRequest( + 'POST', + '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/schedule', + $row + ); + + $this->assertStatusCode($this->response, $statusCode); + sleep(1); // make sure Postgres manages the order of the messages + + $response = $this->getDataFromResponse($this->response); + if (isset($response['id'])) { + self::$textToMessageId[$row['message']] = $response['id']; + self::$messageIdToText[$response['id']] = $row['message']; + } + } + + #[When('/^user "([^"]*)" updates scheduled message "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] + public function userUpdatesScheduledMessageInRoom(string $user, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { + $row = $formData->getRowsHash(); + $id = self::$textToMessageId[$message]; + $row['sendAt'] = (int)$row['sendAt']; + + $this->setCurrentUser($user); + $this->sendRequest( + 'POST', + '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/schedule/' . $id, + $row + ); + + $this->assertStatusCode($this->response, $statusCode); + if ($this->response->getStatusCode() !== 202) { + return; + } + sleep(1); // make sure Postgres manages the order of the messages + + $response = $this->getDataFromResponse($this->response); + self::$textToMessageId[$row['message']] = $response['id']; + self::$messageIdToText[$response['id']] = $row['message']; + Assert::assertEquals($row['message'], $response['message']); + Assert::assertEquals($row['sendAt'], $response['sendAt']); + Assert::assertArrayHasKey('metaData', $response); + $metaData = $response['metaData']; + Assert::assertArrayHasKey('silent', $metaData); + Assert::assertArrayHasKey('threadTitle', $metaData); + Assert::assertArrayHasKey('threadId', $metaData); + Assert::assertArrayHasKey('lastEditedTime', $metaData); + if (isset($row['silent'])) { + Assert::assertEquals($metaData['silent'], (bool)$row['silent']); + } + if (isset($row['threadTitle'])) { + Assert::assertEquals($metaData['threadTitle'], (bool)$row['threadTitle']); + } + } + + #[When('/^user "([^"]*)" deletes scheduled message "([^"]*)" from room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] + public function userDeletesScheduledMessageFromRoom(string $user, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { + $this->setCurrentUser($user); + $this->sendRequest( + 'DELETE', + '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/schedule/' . self::$textToMessageId[$message], + ); + + $this->assertStatusCode($this->response, $statusCode); + } + + #[Then('/^user "([^"]*)" sees the following scheduled messages in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] + public function userSeesTheFollowingScheduledMessagesInRoom(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { + $this->setCurrentUser($user); + $this->sendRequest( + 'GET', + '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/schedule', + ); + $this->assertStatusCode($this->response, $statusCode); + + if ($statusCode === 304) { + return; + } + + $data = $this->getDataFromResponse($this->response); + foreach ($data as &$message) { + Assert::assertArrayHasKey('createdAt', $message); + Assert::assertIsInt($message['createdAt']); + unset($message['createdAt']); + $metaData = $message['metaData']; + if (isset($metaData['lastEditedTime'])) { + $metaData['lastEditedTime'] = 0; + } + if (isset($message['parent'])) { + $parent = $message['parent']; + Assert::assertArrayHasKey('message', $parent); + Assert::assertArrayHasKey('actorId', $parent); + $message['parent'] = self::$messageIdToText[$parent['id']]; + } + $message['metaData'] = $metaData; + } + + $expected = $formData->getColumnsHash(); + foreach ($expected as &$row) { + $row['id'] = self::$textToMessageId[$row['message']]; + $row['sendAt'] = (int)$row['sendAt']; + $row['metaData'] = json_decode($row['metaData'], true); + $row['roomId'] = self::$identifierToId[$row['roomId']]; + $row['parentId'] = ($row['parentId'] === 'null' ? null : self::$textToMessageId[$row['parentId']]); + if (isset($row['parent'])) { + $parent = []; + } + if ($row['threadId'] === '-1') { + $row['threadId'] = -1; + $row['threadExists'] = false; + $row['threadTitle'] = $row['metaData']['threadTitle']; + } elseif ($row['threadId'] !== '0') { + $row['threadId'] = self::$titleToThreadId[$row['threadId']]; + $row['threadTitle'] = self::$threadIdToTitle[$row['threadId']]; + $row['threadExists'] = true; + $row['metaData']['threadId'] = $row['threadId']; + } else { + $row['threadId'] = (int)$row['threadId']; + } + } + Assert::assertEquals($expected, $data); + } + #[Then('/^user "([^"]*)" (silent sends|sends) message ("[^"]*"|\'[^\']*\') to room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSendsMessageToRoom(string $user, string $sendingMode, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->userPostThreadToRoom($user, $sendingMode, '', $message, $identifier, $statusCode, $apiVersion); diff --git a/tests/integration/features/chat-4/scheduled-messages.feature b/tests/integration/features/chat-4/scheduled-messages.feature new file mode 100644 index 0000000000..50f576dacb --- /dev/null +++ b/tests/integration/features/chat-4/scheduled-messages.feature @@ -0,0 +1,111 @@ +Feature: chat-4/scheduling + Background: + Given user "participant1" exists + Given user "participant2" exists + 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 "participant2" sends message "Message" to room "room" with 201 + + Scenario: Schedule a message + When user "participant1" schedules a message to room "room" with 201 + | message | Message 1 | + | sendAt | 1985514582 | + Then user "participant1" sees the following scheduled messages in room "room" with 200 + | id | roomId | actorType | actorId | threadId | parentId | message | messageType | sendAt | metaData | + | Message 1 | room | users | participant1 | 0 | null | Message 1 | comment | 1985514582 | {"threadTitle":"","silent":false,"threadId":0} | + + Scenario: Schedule a silent message + When user "participant1" schedules a message to room "room" with 201 + | message | Message 2 | + | sendAt | 1985514582 | + | silent | true | + Then user "participant1" sees the following scheduled messages in room "room" with 200 + | id | roomId | actorType | actorId | threadId | parentId | message | messageType | sendAt | metaData | + | Message 2 | room | users | participant1 | 0 | null | Message 2 | comment | 1985514582 | {"threadTitle":"","silent":true,"threadId":0} | + + Scenario: Schedule a message reply + When user "participant1" schedules a message to room "room" with 201 + | message | Message 3 | + | sendAt | 1985514582 | + | replyTo | Message | + Then user "participant1" sees the following scheduled messages in room "room" with 200 + | id | roomId | actorType | actorId | threadId | parentId | parent | message | messageType | sendAt | metaData | + | Message 3 | room | users | participant1 | 0 | Message | Message |Message 3 | comment | 1985514582 | {"threadTitle":"","silent":false,"threadId":0} | + + Scenario: Schedule a thread + When user "participant1" schedules a message to room "room" with 201 + | message | Message 4 | + | sendAt | 1985514582 | + | threadTitle | Scheduled Thread | + Then user "participant1" sees the following scheduled messages in room "room" with 200 + | id | roomId | actorType | actorId | threadId | parentId | message | messageType | sendAt | metaData | + | Message 4 | room | users | participant1 | -1 | null | Message 4 | comment | 1985514582 | {"threadTitle":"Scheduled Thread","silent":false,"threadId":-1} | + + Scenario: Schedule a thread reply + Given user "participant1" sends thread "Thread 1" with message "Message 0" to room "room" with 201 + When user "participant1" schedules a message to room "room" with 201 + | message | Message 5 | + | sendAt | 1985514582 | + | threadId | Thread 1 | + Then user "participant1" sees the following scheduled messages in room "room" with 200 + | id | roomId | actorType | actorId | threadId | parentId | message | messageType | sendAt | metaData | + | Message 5 | room | users | participant1 | Thread 1 | null | Message 5 | comment | 1985514582 | {"threadTitle":"Thread 1","silent":false,"threadId":"Thread 1"} | + + Scenario: Schedule a quoted thread reply + Given user "participant1" sends thread "Thread 1" with message "Message 0" to room "room" with 201 + When user "participant1" schedules a message to room "room" with 201 + | message | Message 5 | + | sendAt | 1985514582 | + | replyTo | Message 0 | + | threadId | Thread 1 | + Then user "participant1" sees the following scheduled messages in room "room" with 200 + | id | roomId | actorType | actorId | threadId | parentId | parent | message | messageType | sendAt | metaData | + | Message 5 | room | users | participant1 | Thread 1 | Message 0 | Message 0 | Message 5 | comment | 1985514582 | {"threadTitle":"Thread 1","silent":false,"threadId":"Thread 1"} | + + Scenario: Schedule two messages and delete the first + When user "participant1" schedules a message to room "room" with 201 + | message | Message 1 | + | sendAt | 1985514582 | + | silent | false | + When user "participant1" schedules a message to room "room" with 201 + | message |Message 2 | + | sendAt |1985514584 | + | silent | true | + Then user "participant1" sees the following scheduled messages in room "room" with 200 + | id | roomId | actorType | actorId | threadId | parentId | message | messageType | sendAt | metaData | + | Message 1 | room | users | participant1 | 0 | null | Message 1 | comment | 1985514582 | {"threadTitle":"","silent":false,"threadId":0} | + | Message 2 | room | users | participant1 | 0 | null | Message 2 | comment | 1985514584 | {"threadTitle":"","silent":true,"threadId":0} | + When user "participant1" deletes scheduled message "Message 1" from room "room" with 200 + Then user "participant1" sees the following scheduled messages in room "room" with 200 + | id | roomId | actorType | actorId | threadId | parentId | message | messageType | sendAt | metaData | + | Message 2 | room | users | participant1 | 0 | null | Message 2 | comment | 1985514584 | {"threadTitle":"","silent":true,"threadId":0} | + + Scenario: edit a scheduled message + When user "participant1" schedules a message to room "room" with 201 + | message | Message 1 | + | sendAt | 1985514582 | + Then user "participant1" sees the following scheduled messages in room "room" with 200 + | id | roomId | actorType | actorId | threadId | parentId | message | messageType | sendAt | metaData | + | Message 1 | room | users | participant1 | 0 | null | Message 1 | comment | 1985514582 | {"threadTitle":"","silent":false,"threadId":0} | + When user "participant1" updates scheduled message "Message 1" in room "room" with 202 + | message | Message 1 edited | + | sendAt | 1985514582 | + Then user "participant1" sees the following scheduled messages in room "room" with 200 + | id | roomId | actorType | actorId | threadId | parentId | message | messageType | sendAt | metaData | + | Message 1 edited | room | users | participant1 | 0 | null | Message 1 edited | comment | 1985514582 | {"threadTitle":"","silent":false,"threadId":0,"lastEditedTime":0} | + When user "participant1" updates scheduled message "Message 1" in room "room" with 202 + | message | Message 1 edited | + | sendAt | 1985514582 | + | silent | true | + Then user "participant1" sees the following scheduled messages in room "room" with 200 + | id | roomId | actorType | actorId | threadId | parentId | message | messageType | sendAt | metaData | + | Message 1 edited | room | users | participant1 | 0 | null | Message 1 edited | comment | 1985514582 | {"threadTitle":"","silent":true,"threadId":0,"lastEditedTime":0} | + When user "participant1" updates scheduled message "Message 1" in room "room" with 400 + | message | Message 1 edited | + | sendAt | 1985514582 | + | threadTitle | Abcd | + Then user "participant1" sees the following scheduled messages in room "room" with 200 + | id | roomId | actorType | actorId | threadId | parentId | message | messageType | sendAt | metaData | + | Message 1 edited | room | users | participant1 | 0 | null | Message 1 edited | comment | 1985514582 | {"threadTitle":"","silent":true,"threadId":0,"lastEditedTime":0} | diff --git a/tests/integration/spreedcheats/lib/Controller/ApiController.php b/tests/integration/spreedcheats/lib/Controller/ApiController.php index c9ef896e1c..c1050cd5a8 100644 --- a/tests/integration/spreedcheats/lib/Controller/ApiController.php +++ b/tests/integration/spreedcheats/lib/Controller/ApiController.php @@ -86,6 +86,9 @@ class ApiController extends OCSController { $delete = $this->db->getQueryBuilder(); $delete->delete('talk_rooms')->executeStatement(); + $delete = $this->db->getQueryBuilder(); + $delete->delete('talk_scheduled_msg')->executeStatement(); + $delete = $this->db->getQueryBuilder(); $delete->delete('talk_sessions')->executeStatement(); diff --git a/tests/php/Controller/ChatControllerTest.php b/tests/php/Controller/ChatControllerTest.php index 937ec6cb37..e2fc8e7bf9 100644 --- a/tests/php/Controller/ChatControllerTest.php +++ b/tests/php/Controller/ChatControllerTest.php @@ -29,6 +29,7 @@ use OCA\Talk\Service\ParticipantService; use OCA\Talk\Service\ProxyCacheMessageService; use OCA\Talk\Service\ReminderService; use OCA\Talk\Service\RoomFormatter; +use OCA\Talk\Service\ScheduledMessageService; use OCA\Talk\Service\SessionService; use OCA\Talk\Service\ThreadService; use OCA\Talk\Share\Helper\Preloader; @@ -97,6 +98,7 @@ class ChatControllerTest extends TestCase { private ?ChatController $controller = null; private Callback $newMessageDateTimeConstraint; + private MockObject&ScheduledMessageService $scheduledMessageService; public function setUp(): void { parent::setUp(); @@ -135,6 +137,7 @@ class ChatControllerTest extends TestCase { $this->taskProcessingManager = $this->createMock(ITaskProcessingManager::class); $this->appConfig = $this->createMock(IAppConfig::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->scheduledMessageService = $this->createMock(ScheduledMessageService::class); $this->room = $this->createMock(Room::class); @@ -148,7 +151,7 @@ class ChatControllerTest extends TestCase { }); } - private function recreateChatController() { + private function recreateChatController(): void { $this->controller = new ChatController( 'spreed', $this->userId, @@ -186,10 +189,11 @@ class ChatControllerTest extends TestCase { $this->taskProcessingManager, $this->appConfig, $this->logger, + $this->scheduledMessageService, ); } - private function newComment($id, $actorType, $actorId, $creationDateTime, $message) { + private function newComment($id, $actorType, $actorId, $creationDateTime, $message): IComment&MockObject { $comment = $this->createMock(IComment::class); $comment->method('getId')->willReturn($id); @@ -209,7 +213,6 @@ class ChatControllerTest extends TestCase { $this->timeFactory->expects($this->once()) ->method('getDateTime') ->willReturn($date); - /** @var IComment&MockObject $comment */ $comment = $this->newComment(42, 'user', $this->userId, $date, 'testMessage'); $this->chatManager->expects($this->once()) ->method('sendMessage') @@ -280,7 +283,6 @@ class ChatControllerTest extends TestCase { $this->timeFactory->expects($this->once()) ->method('getDateTime') ->willReturn($date); - /** @var IComment&MockObject $comment */ $comment = $this->newComment(42, 'user', $this->userId, $date, 'testMessage'); $this->chatManager->expects($this->once()) ->method('sendMessage') @@ -355,7 +357,6 @@ class ChatControllerTest extends TestCase { /** @var IComment&MockObject $comment */ $parent = $this->newComment(23, 'users', $this->userId . '2', $date, 'testMessage original'); - /** @var IComment&MockObject $comment */ $comment = $this->newComment(42, 'users', $this->userId, $date, 'testMessage'); $this->chatManager->expects($this->once()) ->method('sendMessage') @@ -513,7 +514,6 @@ class ChatControllerTest extends TestCase { $this->timeFactory->expects($this->once()) ->method('getDateTime') ->willReturn($date); - /** @var IComment&MockObject $comment */ $comment = $this->newComment(23, 'user', $this->userId, $date, 'testMessage'); $this->chatManager->expects($this->once()) ->method('sendMessage') @@ -592,7 +592,6 @@ class ChatControllerTest extends TestCase { $this->timeFactory->expects($this->once()) ->method('getDateTime') ->willReturn($date); - /** @var IComment&MockObject $comment */ $comment = $this->newComment(64, 'guest', sha1('testSpreedSession'), $date, 'testMessage'); $this->chatManager->expects($this->once()) ->method('sendMessage') @@ -674,7 +673,6 @@ class ChatControllerTest extends TestCase { $this->timeFactory->expects($this->once()) ->method('getDateTime') ->willReturn($date); - /** @var IComment&MockObject $comment */ $comment = $this->newComment(42, 'user', $this->userId, $date, 'testMessage'); $this->chatManager->expects($this->once()) ->method('addSystemMessage') @@ -963,7 +961,7 @@ class ChatControllerTest extends TestCase { public function testWaitForNewMessagesByUser(): void { $testUser = $this->createMock(IUser::class); - $testUser->expects($this->any()) + $testUser ->method('getUID') ->willReturn('testUser'); @@ -1044,7 +1042,7 @@ class ChatControllerTest extends TestCase { public function testWaitForNewMessagesTimeoutExpired(): void { $participant = $this->createMock(Participant::class); $testUser = $this->createMock(IUser::class); - $testUser->expects($this->any()) + $testUser ->method('getUID') ->willReturn('testUser'); @@ -1056,7 +1054,7 @@ class ChatControllerTest extends TestCase { ->with($this->room, $offset, $limit, $timeout, $testUser) ->willReturn([]); - $this->userManager->expects($this->any()) + $this->userManager ->method('get') ->with('testUser') ->willReturn($testUser); @@ -1072,7 +1070,7 @@ class ChatControllerTest extends TestCase { public function testWaitForNewMessagesTimeoutTooLarge(): void { $participant = $this->createMock(Participant::class); $testUser = $this->createMock(IUser::class); - $testUser->expects($this->any()) + $testUser ->method('getUID') ->willReturn('testUser'); @@ -1085,7 +1083,7 @@ class ChatControllerTest extends TestCase { ->with($this->room, $offset, $limit, $maximumTimeout, $testUser) ->willReturn([]); - $this->userManager->expects($this->any()) + $this->userManager ->method('get') ->with('testUser') ->willReturn($testUser); @@ -1129,7 +1127,7 @@ class ChatControllerTest extends TestCase { #[DataProvider('dataMentions')] public function testMentions(string $search, int $limit, array $result, array $expected): void { $participant = $this->createMock(Participant::class); - $this->room->expects($this->any()) + $this->room ->method('getId') ->willReturn(1234);