Skip to content

Commit f757d59

Browse files
miaulalalanickvergessen
authored andcommitted
feat: add scheduled messages BG job
Signed-off-by: Anna Larch <[email protected]>
1 parent ed3723e commit f757d59

27 files changed

+955
-146
lines changed

appinfo/info.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
* 🌉 **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.
1919
]]></description>
2020

21-
<version>23.0.0-dev.3</version>
21+
<version>23.0.0-dev.4</version>
2222
<licence>agpl</licence>
2323

2424
<author>Anna Larch</author>
@@ -78,6 +78,7 @@
7878
<job>OCA\Talk\BackgroundJob\RemoveEmptyRooms</job>
7979
<job>OCA\Talk\BackgroundJob\ResetAssignedSignalingServer</job>
8080
<job>OCA\Talk\BackgroundJob\RetryNotificationsJob</job>
81+
<job>OCA\Talk\BackgroundJob\SendScheduledMessages</job>
8182
</background-jobs>
8283

8384
<repair-steps>
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Talk\BackgroundJob;
11+
12+
use OCA\Talk\Chat\ChatManager;
13+
use OCA\Talk\Chat\MessageParser;
14+
use OCA\Talk\Exceptions\ParticipantNotFoundException;
15+
use OCA\Talk\Exceptions\RoomNotFoundException;
16+
use OCA\Talk\Manager;
17+
use OCA\Talk\Model\Attendee;
18+
use OCA\Talk\Model\ScheduledMessage;
19+
use OCA\Talk\Model\Thread;
20+
use OCA\Talk\Participant;
21+
use OCA\Talk\Room;
22+
use OCA\Talk\Service\ParticipantService;
23+
use OCA\Talk\Service\ScheduledMessageService;
24+
use OCA\Talk\Service\ThreadService;
25+
use OCA\Talk\Webinary;
26+
use OCP\AppFramework\Utility\ITimeFactory;
27+
use OCP\BackgroundJob\TimedJob;
28+
use OCP\Comments\MessageTooLongException;
29+
use OCP\Comments\NotFoundException;
30+
use OCP\IL10N;
31+
use Psr\Log\LoggerInterface;
32+
33+
/**
34+
* Class SendScheduledMessages
35+
*
36+
* @package OCA\Talk\BackgroundJob
37+
*/
38+
class SendScheduledMessages extends TimedJob {
39+
public function __construct(
40+
private readonly ScheduledMessageService $scheduledMessageService,
41+
private readonly ParticipantService $participantService,
42+
private readonly Manager $manager,
43+
private readonly ChatManager $chatManager,
44+
private readonly MessageParser $messageParser,
45+
private readonly ThreadService $threadService,
46+
private readonly IL10n $l10n,
47+
private readonly LoggerInterface $logger,
48+
ITimeFactory $time,
49+
) {
50+
// Every minute
51+
$this->setInterval(60);
52+
parent::__construct($time);
53+
}
54+
55+
/**
56+
* @inheritDoc
57+
*/
58+
#[\Override]
59+
protected function run($argument): void {
60+
$time = $this->time->getDateTime('-1 second');
61+
$messages = $this->scheduledMessageService->getDue($time);
62+
if (empty($messages)) {
63+
$this->logger->debug('No messages found');
64+
return;
65+
}
66+
67+
/** @var list<Room> $rooms */
68+
$rooms = [];
69+
foreach ($messages as $message) {
70+
if (!isset($rooms[$message->getRoomId()])) {
71+
try {
72+
$rooms[$message->getRoomId()] = $this->manager->getRoomById($message->getRoomId());
73+
} catch (RoomNotFoundException) {
74+
$this->logger->warning('Room not found: ' . $message->getRoomId());
75+
continue;
76+
}
77+
}
78+
79+
$room = $rooms[$message->getRoomId()];
80+
try {
81+
$participant = $this->participantService->getParticipantByActor($room, $message->getActorType(), $message->getActorId());
82+
} catch (ParticipantNotFoundException $e) {
83+
$this->scheduledMessageService->deleteMessage(
84+
$room,
85+
(string)$message->getId(),
86+
$message->getActorType(),
87+
$message->getActorId()
88+
);
89+
continue;
90+
}
91+
92+
if ($room->isFederatedConversation() || $room->getType() === Room::TYPE_ONE_TO_ONE_FORMER) {
93+
$this->logger->warning('Cannot send scheduled message to conversation of type ' . $room->getType() . ' with id ' . $room->getId() . ' for ' . $message->getActorType() . ' ' . $message->getActorId() . ', removing scheduled message ' . $message->getId());
94+
$this->scheduledMessageService->deleteMessage(
95+
$room,
96+
(string)$message->getId(),
97+
$participant->getAttendee()->getActorType(),
98+
$participant->getAttendee()->getActorId()
99+
);
100+
$this->participantService->setHasScheduledMessages($participant, Participant::ERROR_SCHEDULED_MESSAGE);
101+
continue;
102+
}
103+
104+
if ($room->getReadOnly() === Room::READ_ONLY) {
105+
$this->logger->warning('Cannot send scheduled message ' . $message->getId() . ' to read only room ' . $room->getId() . ' for ' . $message->getActorType() . ' ' . $message->getActorId());
106+
$this->scheduledMessageService->markAsFailed($message);
107+
$this->participantService->setHasScheduledMessages($participant, Participant::ERROR_SCHEDULED_MESSAGE);
108+
continue;
109+
}
110+
111+
if ($room->getLobbyState() !== Webinary::LOBBY_NONE && ($participant->getPermissions() & Attendee::PERMISSIONS_LOBBY_IGNORE) === 0) {
112+
$this->logger->warning('User ' . $message->getActorId() . ' has no chat permissions for room ' . $message->getRoomId() . ', could not send scheduled message ' . $message->getId() . ' for ' . $message->getActorType() . ' ' . $message->getActorId());
113+
$this->scheduledMessageService->markAsFailed($message);
114+
$this->participantService->setHasScheduledMessages($participant, Participant::ERROR_SCHEDULED_MESSAGE);
115+
continue;
116+
}
117+
118+
if (($participant->getPermissions() & Attendee::PERMISSIONS_CHAT) === 0) {
119+
$this->logger->warning('User ' . $message->getActorId() . ' has no chat permissions for room ' . $message->getRoomId() . ', could not send scheduled message ' . $message->getId() . ' for ' . $message->getActorType() . ' ' . $message->getActorId());
120+
$this->scheduledMessageService->markAsFailed($message);
121+
$this->participantService->setHasScheduledMessages($participant, Participant::ERROR_SCHEDULED_MESSAGE);
122+
continue;
123+
}
124+
125+
$parent = $parentMessage = null;
126+
if ($message->getParentId() !== 0 && $message->getParentId() !== null) {
127+
try {
128+
$parent = $this->chatManager->getParentComment($room, (string)$message->getParentId());
129+
$parentMessage = $this->messageParser->createMessage($room, $participant, $parent, $this->l10n);
130+
$this->messageParser->parseMessage($parentMessage);
131+
if (!$parentMessage->isReplyable()) {
132+
$parentMessageId = $message->getParentId() ?? 0;
133+
$this->logger->warning('Parent ' . $parentMessageId . ' in room ' . $message->getRoomId() . ' for scheduled message ' . $message->getId() . ' not replyable for ' . $message->getActorType() . ' ' . $message->getActorId());
134+
$this->scheduledMessageService->markAsFailed($message);
135+
$this->participantService->setHasScheduledMessages($participant, Participant::ERROR_SCHEDULED_MESSAGE);
136+
continue;
137+
}
138+
} catch (NotFoundException $e) {
139+
$parentMessageId = $message->getParentId() ?? 0;
140+
$this->logger->warning('Parent ' . $parentMessageId . ' in room ' . $message->getRoomId() . ' for scheduled message ' . $message->getId() . ' for ' . $message->getActorType() . ' ' . $message->getActorId() . ' not found', ['exception' => $e]);
141+
$this->scheduledMessageService->markAsFailed($message);
142+
$this->participantService->setHasScheduledMessages($participant, Participant::ERROR_SCHEDULED_MESSAGE);
143+
continue;
144+
}
145+
} elseif ($message->getThreadId() !== 0 && $message->getThreadId() !== -1) {
146+
if (!$this->threadService->validateThread($room->getId(), $message->getThreadId())) {
147+
$message->setThreadId(0);
148+
$this->logger->warning('Could not validate thread ' . $message->getThreadId() . ' in room ' . $message->getRoomId() . ' for scheduled message ' . $message->getId() . ' for ' . $message->getActorType() . ' ' . $message->getActorId());
149+
$this->scheduledMessageService->markAsFailed($message);
150+
$this->participantService->setHasScheduledMessages($participant, Participant::ERROR_SCHEDULED_MESSAGE);
151+
continue;
152+
}
153+
}
154+
155+
$this->participantService->ensureOneToOneRoomIsFilled($room);
156+
try {
157+
$this->logger->debug('Sending scheduled message ' . $message->getId() . ' to room ' . $message->getRoomId() . ' for ' . $message->getActorType() . ' ' . $message->getActorId());
158+
$metaData = $message->getDecodedMetaData();
159+
$threadId = $message->getThreadId();
160+
$threadTitle = $metaData[ScheduledMessage::METADATA_THREAD_TITLE] ?? null;
161+
$comment = $this->chatManager->sendMessage($room,
162+
$participant,
163+
$message->getActorType(),
164+
$message->getActorId(),
165+
$message->getMessage(),
166+
$this->time->getDateTime(),
167+
$parent,
168+
'',
169+
$metaData[ScheduledMessage::METADATA_SILENT] ?? false,
170+
threadId: $threadId
171+
);
172+
$this->logger->debug('Sent scheduled message ' . $message->getId() . ' to room ' . $message->getRoomId() . ' for ' . $message->getActorType() . ' ' . $message->getActorId());
173+
if ($threadId === Thread::THREAD_CREATE && $threadTitle !== '') {
174+
$thread = $this->threadService->createThread($room, (int)$comment->getId(), $threadTitle);
175+
// Add to subscribed threads list
176+
$this->threadService->setNotificationLevel($participant->getAttendee(), $thread->getId(), Participant::NOTIFY_DEFAULT);
177+
$this->chatManager->addSystemMessage(
178+
$room,
179+
$participant,
180+
$participant->getAttendee()->getActorType(),
181+
$participant->getAttendee()->getActorId(),
182+
json_encode(['message' => 'thread_created', 'parameters' => ['thread' => (int)$comment->getId(), 'title' => $thread->getName()]]),
183+
$this->time->getDateTime(),
184+
false,
185+
null,
186+
$comment,
187+
true,
188+
true
189+
);
190+
$this->logger->debug('Created thread ' . $thread->getId() . ' in room ' . $message->getRoomId() . ' for scheduled message ' . $message->getId() . ' for ' . $message->getActorType() . ' ' . $message->getActorId());
191+
}
192+
} catch (MessageTooLongException $e) {
193+
$this->logger->error('Sending scheduled message ' . $message->getId() . ' to room ' . $message->getRoomId() . ' for ' . $message->getActorType() . ' ' . $message->getActorId() . ' failed, message too long', ['exception' => $e]);
194+
$this->scheduledMessageService->markAsFailed($message);
195+
$this->participantService->setHasScheduledMessages($participant, Participant::ERROR_SCHEDULED_MESSAGE);
196+
continue;
197+
} catch (\Exception $e) {
198+
$this->logger->error('Sending scheduled message ' . $message->getId() . ' to room ' . $message->getRoomId() . ' for ' . $message->getActorType() . ' ' . $message->getActorId() . ' failed, general exception', ['exception' => $e]);
199+
$this->scheduledMessageService->markAsFailed($message);
200+
$this->participantService->setHasScheduledMessages($participant, Participant::ERROR_SCHEDULED_MESSAGE);
201+
continue;
202+
}
203+
204+
$this->scheduledMessageService->deleteMessage(
205+
$room,
206+
(string)$message->getId(),
207+
$participant->getAttendee()->getActorType(),
208+
$participant->getAttendee()->getActorId()
209+
);
210+
211+
$this->logger->debug('Deleted scheduled message ' . $message->getId() . ' in room ' . $message->getRoomId() . ' for ' . $message->getActorType() . ' ' . $message->getActorId());
212+
$hasScheduledMessages = $this->scheduledMessageService->getScheduledMessageCount($room, $participant);
213+
$this->participantService->setHasScheduledMessages($participant, $hasScheduledMessages);
214+
}
215+
}
216+
}

lib/Chat/Listener.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
namespace OCA\Talk\Chat;
1111

1212
use OCA\Talk\Events\RoomDeletedEvent;
13-
use OCA\Talk\Model\ScheduledMessageMapper;
13+
use OCA\Talk\Service\ScheduledMessageService;
1414
use OCP\EventDispatcher\Event;
1515
use OCP\EventDispatcher\IEventListener;
1616

@@ -20,15 +20,15 @@
2020
class Listener implements IEventListener {
2121
public function __construct(
2222
protected ChatManager $chatManager,
23-
protected ScheduledMessageMapper $scheduledMessageMapper,
23+
protected ScheduledMessageService $scheduledMessageService,
2424
) {
2525
}
2626

2727
#[\Override]
2828
public function handle(Event $event): void {
2929
if ($event instanceof RoomDeletedEvent) {
3030
$this->chatManager->deleteMessages($event->getRoom());
31-
$this->scheduledMessageMapper->deleteMessagesByRoom($event->getRoom());
31+
$this->scheduledMessageService->deleteMessagesByRoom($event->getRoom());
3232
}
3333
}
3434
}

lib/Controller/ChatController.php

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,8 @@ public function scheduleMessage(
461461
ScheduledMessage::METADATA_THREAD_ID => $threadId,
462462
]
463463
);
464-
$this->participantService->setHasScheduledMessages($this->participant, true);
464+
$count = $this->scheduledMessageManager->getScheduledMessageCount($this->room, $this->participant);
465+
$this->participantService->setHasScheduledMessages($this->participant, $count);
465466
} catch (MessageTooLongException) {
466467
return new DataResponse(['error' => 'message'], Http::STATUS_REQUEST_ENTITY_TOO_LARGE);
467468
}
@@ -475,7 +476,7 @@ public function scheduleMessage(
475476
*
476477
* Required capability: `scheduled-messages`
477478
*
478-
* @param int $messageId The scheduled message id
479+
* @param string $messageId The scheduled message id
479480
* @param string $message The scheduled message to send
480481
* @param int $sendAt When to send the scheduled message
481482
* @param bool $silent If sent silent the scheduled message will not create any notifications
@@ -495,10 +496,10 @@ public function scheduleMessage(
495496
#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/chat/{token}/schedule/{messageId}', requirements: [
496497
'apiVersion' => '(v1)',
497498
'token' => '[a-z0-9]{4,30}',
498-
'messageId' => '[0-9]{4,30}',
499+
'messageId' => '[0-9]+',
499500
])]
500501
public function editScheduledMessage(
501-
int $messageId,
502+
string $messageId,
502503
string $message,
503504
int $sendAt,
504505
bool $silent = false,
@@ -527,7 +528,8 @@ public function editScheduledMessage(
527528
$sendAtDateTime,
528529
$threadTitle
529530
);
530-
$this->participantService->setHasScheduledMessages($this->participant, true);
531+
$count = $this->scheduledMessageManager->getScheduledMessageCount($this->room, $this->participant);
532+
$this->participantService->setHasScheduledMessages($this->participant, $count);
531533
} catch (MessageTooLongException) {
532534
return new DataResponse(['error' => 'message'], Http::STATUS_REQUEST_ENTITY_TOO_LARGE);
533535
} catch (\InvalidArgumentException) {
@@ -555,7 +557,7 @@ public function editScheduledMessage(
555557
*
556558
* Required capability: `scheduled-messages`
557559
*
558-
* @param int $messageId The scheduled message ud
560+
* @param string $messageId The scheduled message id
559561
* @return DataResponse<Http::STATUS_OK, array{}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: 'actor'|'message'}, array{}>
560562
*
561563
* 200: Message deleted
@@ -569,25 +571,26 @@ public function editScheduledMessage(
569571
#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/chat/{token}/schedule/{messageId}', requirements: [
570572
'apiVersion' => '(v1)',
571573
'token' => '[a-z0-9]{4,30}',
572-
'messageId' => '[0-9]{4,30}',
574+
'messageId' => '[0-9]+',
573575
])]
574-
public function deleteScheduleMessage(int $messageId): DataResponse {
576+
public function deleteScheduleMessage(string $messageId): DataResponse {
575577
if ($this->participant->isSelfJoinedOrGuest()) {
576578
return new DataResponse(['error' => 'actor'], Http::STATUS_NOT_FOUND);
577579
}
578580

579581
$deleted = $this->scheduledMessageManager->deleteMessage(
580582
$this->room,
581583
$messageId,
582-
$this->participant,
584+
$this->participant->getAttendee()->getActorType(),
585+
$this->participant->getAttendee()->getActorId()
583586
);
584587

585588
if ($deleted === 0) {
586589
return new DataResponse(['error' => 'message'], Http::STATUS_NOT_FOUND);
587590
}
588591

589-
$hasScheduledMessages = $this->scheduledMessageManager->getScheduledMessageCount($this->room, $this->participant) > 0;
590-
$this->participantService->setHasScheduledMessages($this->participant, $hasScheduledMessages);
592+
$count = $this->scheduledMessageManager->getScheduledMessageCount($this->room, $this->participant);
593+
$this->participantService->setHasScheduledMessages($this->participant, $count);
591594
return new DataResponse([], Http::STATUS_OK);
592595
}
593596

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Talk\Migration;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\Migration\IOutput;
15+
use OCP\Migration\SimpleMigrationStep;
16+
use Override;
17+
18+
class Version23000Date20251215204457 extends SimpleMigrationStep {
19+
/**
20+
* @param IOutput $output
21+
* @param Closure(): ISchemaWrapper $schemaClosure
22+
* @param array $options
23+
* @return null|ISchemaWrapper
24+
*/
25+
#[Override]
26+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
27+
$schema = $schemaClosure();
28+
29+
$table = $schema->getTable('talk_attendees');
30+
if ($table->hasColumn('has_scheduled_messages')) {
31+
$table->dropColumn('has_scheduled_messages');
32+
}
33+
return $schema;
34+
}
35+
}

0 commit comments

Comments
 (0)