feat: scheduled message API

Signed-off-by: Anna Larch <anna@nextcloud.com>
This commit is contained in:
Anna Larch 2025-11-08 12:58:40 +01:00
parent f8778c9734
commit ef3265c9c7
30 changed files with 4181 additions and 21 deletions

View file

@ -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.
]]></description>
<version>23.0.0-dev.2</version>
<version>23.0.0-dev.3</version>
<licence>agpl</licence>
<author>Anna Larch</author>

View file

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

View file

@ -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 = [

View file

@ -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());
}
}
}

View file

@ -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<Http::STATUS_OK, list<TalkScheduledMessage>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: 'actor'}, array{}>
*
* 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<Http::STATUS_CREATED, TalkScheduledMessage, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'message'|'reply-to'|'send-at'}, array{}>|DataResponse<Http::STATUS_REQUEST_ENTITY_TOO_LARGE, array{error: 'message'}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: 'actor'}, array{}>
*
* 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<Http::STATUS_ACCEPTED, TalkScheduledMessage, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'message'|'send-at'|'thread-title'}, array{}>|DataResponse<Http::STATUS_REQUEST_ENTITY_TOO_LARGE, array{error: 'message'}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: 'actor'|'message'}, array{}>
*
* 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<Http::STATUS_OK, array{}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: 'actor'|'message'}, array{}>
*
* 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
*

View file

@ -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<Http::STATUS_NOT_FOUND, null, array{}> $response

View file

@ -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());
}
}

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Override;
class Version23000Date20251105125333 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
#[Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
$schema = $schemaClosure();
if (!$schema->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;
}
}

View file

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

View file

@ -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';

View file

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Model;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\ResponseDefinitions;
use OCP\AppFramework\Db\Entity;
use OCP\Comments\MessageTooLongException;
use OCP\DB\Types;
/**
* @method string getId()
* @method void setId(string $id)
* @method void setRoomId(int $roomId)
* @method int getRoomId()
* @method void setActorId(string $actorId)
* @method string getActorId()
* @method void setActorType(string $actorType)
* @method string getActorType()
* @method int getThreadId()
* @method void setThreadId(int $threadId)
* @method int|null getParentId()
* @method void setParentId(int|null $parentId)
* @method string getMessage()
* @method void setMessageType(string $messageType)
* @method string getMessageType()
* @method \DateTime getCreatedAt()
* @method void setCreatedAt(\DateTime $createdAt)
* @method void setSendAt(\DateTime|null $sendAt)
* @method \DateTime|null getSendAt()
*
* @psalm-import-type TalkScheduledMessage from ResponseDefinitions
* @psalm-import-type TalkScheduledMessageMetaData from ResponseDefinitions
*/
class ScheduledMessage extends Entity implements \JsonSerializable {
public const METADATA_THREAD_TITLE = 'threadTitle';
public const METADATA_THREAD_ID = 'threadId';
public const METADATA_SILENT = 'silent';
public const METADATA_LAST_EDITED_TIME = 'lastEditedTime';
protected ?int $roomId = 0;
protected string $actorId = '';
protected string $actorType = '';
protected int $threadId = 0;
protected ?int $parentId = null;
protected string $message = '';
protected string $messageType = '';
protected ?string $metaData = null;
protected ?\DateTime $createdAt = null;
protected ?\DateTime $sendAt = null;
public function __construct() {
$this->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;
}
}

View file

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Model;
use OCA\Talk\Room;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\Entity;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\Snowflake\IGenerator;
/**
* @method ScheduledMessage mapRowToEntity(array $row)
* @method ScheduledMessage findEntity(IQueryBuilder $query)
* @method list<ScheduledMessage> findEntities(IQueryBuilder $query)
* @method ScheduledMessage update(ScheduledMessage $scheduledMessage)
* @method ScheduledMessage delete(ScheduledMessage $scheduledMessage)
* @template-extends QBMapper<ScheduledMessage>
*/
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);
}
}

View file

@ -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 .= '.';

View file

@ -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) {

View file

@ -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 {
}

View file

@ -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);
}
}

View file

@ -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();

View file

@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Service;
use OCA\Talk\Chat\CommentsManager;
use OCA\Talk\Chat\MessageParser;
use OCA\Talk\Model\Message;
use OCA\Talk\Model\ScheduledMessage;
use OCA\Talk\Model\ScheduledMessageMapper;
use OCA\Talk\Model\Thread;
use OCA\Talk\Participant;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Room;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Comments\IComment;
use OCP\Comments\MessageTooLongException;
use OCP\DB\Exception;
use OCP\IL10N;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type TalkScheduledMessage from ResponseDefinitions
*/
class ScheduledMessageService {
public function __construct(
private readonly ScheduledMessageMapper $scheduledMessageMapper,
protected ThreadService $threadService,
protected ITimeFactory $timeFactory,
protected IL10N $l,
protected LoggerInterface $logger,
protected CommentsManager $commentsManager,
protected MessageParser $messageParser,
) {
}
/**
* @throws MessageTooLongException When the message is too long (~32k characters)
*/
public function scheduleMessage(
Room $chat,
Participant $participant,
string $message,
string $messageType,
?IComment $parent,
int $threadId,
\DateTime $sendAt,
array $metadata = [],
): ScheduledMessage {
$scheduledMessage = new ScheduledMessage();
$scheduledMessage->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<TalkScheduledMessage>
*/
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(),
);
}
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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"];
};

View file

@ -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"];
};

View file

@ -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<string, never>;
};
};
};
};
/** @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: {

View file

@ -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<string, never>;
};
};
};
};
/** @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: {

View file

@ -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);

View file

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

View file

@ -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();

View file

@ -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);