From e0d41ecb0af52e7e27501a86df658e5d82c66be9 Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Sat, 25 Apr 2026 16:32:21 +0300 Subject: [PATCH 1/7] feat/scheduled-messages --- apps/meteor/app/api/server/index.ts | 1 + .../app/api/server/v1/chat.scheduleMessage.ts | 157 ++++++++++++++++++ .../client/lib/chats/flows/scheduleMessage.ts | 31 ++++ .../room/composer/messageBox/MessageBox.tsx | 7 + .../MessageBoxActionsToolbar.tsx | 7 + .../hooks/useViewScheduledMessagesAction.tsx | 24 +++ .../messageBox/MessageBoxScheduleMenu.tsx | 19 +++ .../messageBox/MessageBoxScheduleModal.tsx | 120 +++++++++++++ .../MessageBoxScheduledMessagesModal.tsx | 116 +++++++++++++ .../hooks/useScheduleMessageButton.tsx | 89 ++++++++++ apps/meteor/server/cron/scheduledMessages.ts | 58 +++++++ apps/meteor/server/startup/cron.ts | 11 +- .../core-typings/src/IMessage/IMessage.ts | 2 + packages/i18n/src/locales/en.i18n.json | 14 ++ packages/models/src/models/Messages.ts | 22 ++- packages/rest-typings/src/v1/chat.ts | 28 ++++ .../rest-typings/src/v1/chat/scheduled.ts | 92 ++++++++++ 17 files changed, 796 insertions(+), 2 deletions(-) create mode 100644 apps/meteor/app/api/server/v1/chat.scheduleMessage.ts create mode 100644 apps/meteor/client/lib/chats/flows/scheduleMessage.ts create mode 100644 apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useViewScheduledMessagesAction.tsx create mode 100644 apps/meteor/client/views/room/composer/messageBox/MessageBoxScheduleMenu.tsx create mode 100644 apps/meteor/client/views/room/composer/messageBox/MessageBoxScheduleModal.tsx create mode 100644 apps/meteor/client/views/room/composer/messageBox/MessageBoxScheduledMessagesModal.tsx create mode 100644 apps/meteor/client/views/room/composer/messageBox/hooks/useScheduleMessageButton.tsx create mode 100644 apps/meteor/server/cron/scheduledMessages.ts create mode 100644 packages/rest-typings/src/v1/chat/scheduled.ts diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index 5a6a6f06cbbab..6c6efcd97c594 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -11,6 +11,7 @@ import './v1/calendar'; import './v1/call-history'; import './v1/channels'; import './v1/chat'; +import './v1/chat.scheduleMessage'; import './v1/cloud'; import './v1/commands'; import './v1/e2e'; diff --git a/apps/meteor/app/api/server/v1/chat.scheduleMessage.ts b/apps/meteor/app/api/server/v1/chat.scheduleMessage.ts new file mode 100644 index 0000000000000..60d2038f157d7 --- /dev/null +++ b/apps/meteor/app/api/server/v1/chat.scheduleMessage.ts @@ -0,0 +1,157 @@ +// @ts-nocheck +import { Messages, Rooms, Users } from '@rocket.chat/models'; +import { check, Match } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; +import { API } from '../api'; + +API.v1 + .post( + 'chat.scheduleMessage', + { authRequired: true }, + async function action() { + check(this.bodyParams, { + roomId: String, + message: String, + scheduledAt: String, + tmid: Match.Maybe(String), + }); + + const { roomId, message, scheduledAt, tmid } = this.bodyParams; + + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'chat.scheduleMessage' }); + } + + const room = await Rooms.findOneById(roomId); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'chat.scheduleMessage' }); + } + + if (!(await canAccessRoomIdAsync(roomId, this.userId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'chat.scheduleMessage' }); + } + + const scheduledDate = new Date(scheduledAt); + if (isNaN(scheduledDate.getTime())) { + throw new Meteor.Error('error-invalid-date', 'Invalid date format', { method: 'chat.scheduleMessage' }); + } + + if (scheduledDate <= new Date()) { + throw new Meteor.Error('error-past-date', 'Scheduled date must be in the future', { method: 'chat.scheduleMessage' }); + } + + const user = await Users.findOneById(this.userId); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'chat.scheduleMessage' }); + } + + const messageData: any = { + rid: roomId, + msg: message, + u: { + _id: user._id, + username: user.username as string, + name: user.name, + }, + ts: new Date(), + scheduledAt: scheduledDate, + scheduled: true, + ...(tmid && { tmid }), + }; + + const result = await Messages.insertOne(messageData); + + return API.v1.success({ + message: { + ...messageData, + _id: result.insertedId, + _updatedAt: new Date(), + }, + }); + }, + ) + .get( + 'chat.getScheduledMessages', + { authRequired: true }, + async function action() { + const { roomId, count = 50, offset = 0 } = this.queryParams; + + check(roomId, String); + check(count, Match.Maybe(Number)); + check(offset, Match.Maybe(Number)); + + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'chat.getScheduledMessages' }); + } + + if (!(await canAccessRoomIdAsync(roomId, this.userId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'chat.getScheduledMessages' }); + } + + const messages = await Messages.find( + { + rid: roomId, + 'u._id': this.userId, + scheduled: true, + scheduledAt: { $exists: true }, + }, + { + sort: { scheduledAt: 1 }, + skip: Number(offset), + limit: Number(count), + }, + ).toArray(); + + const total = await Messages.countDocuments({ + rid: roomId, + 'u._id': this.userId, + scheduled: true, + scheduledAt: { $exists: true }, + }); + + return API.v1.success({ + messages, + count: messages.length, + offset, + total, + }); + }, + ) + .post( + 'chat.cancelScheduledMessage', + { authRequired: true }, + async function action() { + check(this.bodyParams, { + messageId: String, + }); + + const { messageId } = this.bodyParams; + + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'chat.cancelScheduledMessage' }); + } + + const message = await Messages.findOneById(messageId); + if (!message) { + throw new Meteor.Error('error-message-not-found', 'Scheduled message not found', { + method: 'chat.cancelScheduledMessage', + }); + } + + if (message.u._id !== this.userId) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'chat.cancelScheduledMessage' }); + } + + if (!message.scheduled) { + throw new Meteor.Error('error-not-scheduled', 'Message is not scheduled', { + method: 'chat.cancelScheduledMessage', + }); + } + + await Messages.deleteOne({ _id: messageId }); + + return API.v1.success({ success: true }); + }, + ); diff --git a/apps/meteor/client/lib/chats/flows/scheduleMessage.ts b/apps/meteor/client/lib/chats/flows/scheduleMessage.ts new file mode 100644 index 0000000000000..4b5479ab892bb --- /dev/null +++ b/apps/meteor/client/lib/chats/flows/scheduleMessage.ts @@ -0,0 +1,31 @@ +import type { IRoom } from '@rocket.chat/core-typings'; + +import { t } from '../../../../app/utils/lib/i18n'; +import { sdk } from '../../../../app/utils/client/lib/SDKClient'; +import { dispatchToastMessage } from '../../../lib/toast'; + +export const scheduleMessage = async ({ + rid, + msg, + scheduledAt, + tmid, +}: { + rid: IRoom['_id']; + msg: string; + scheduledAt: Date; + tmid?: string; +}): Promise => { + try { + await sdk.rest.post('/v1/chat.scheduleMessage', { + roomId: rid, + message: msg, + scheduledAt: scheduledAt.toISOString(), + ...(tmid && { tmid }), + }); + + dispatchToastMessage({ type: 'success', message: t('Message_sent') }); + } catch (error: any) { + dispatchToastMessage({ type: 'error', message: error.message || t('Error') }); + throw error; + } +}; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index c653b0a132e55..59b89cba73582 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -22,6 +22,7 @@ import MessageBoxHint from './MessageBoxHint'; import MessageBoxReplies from './MessageBoxReplies'; import MessageComposerFiles from './MessageComposerFiles'; import { handleSelectionWrapping } from './wrapSelection'; +import { useScheduleMessageButton } from './hooks/useScheduleMessageButton'; import { createComposerAPI } from '../../../../../app/ui-message/client/messageBox/createComposerAPI'; import type { FormattingButton } from '../../../../../app/ui-message/client/messageBox/messageBoxFormatting'; import { formattingButtons } from '../../../../../app/ui-message/client/messageBox/messageBoxFormatting'; @@ -148,6 +149,11 @@ const MessageBox = ({ const useEmojis = useUserPreference('useEmojis'); + const { scheduleMenu } = useScheduleMessageButton( + room._id, + tmid, + ); + const handleOpenEmojiPicker = useEffectEvent((e: MouseEvent) => { e.stopPropagation(); e.preventDefault(); @@ -496,6 +502,7 @@ const MessageBox = ({ {canSend && ( <> {isEditing && {t('Cancel')}} + {!isEditing && scheduleMenu} { + const { t } = useTranslation(); + const setModal = useSetModal(); + + return useMemo( + () => ({ + id: 'view-scheduled-messages', + content: t('View_Scheduled_Messages'), + icon: 'clock', + disabled: false, + onClick: () => { + setModal( setModal(null)} />); + }, + }), + [t, roomId, setModal], + ); +}; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxScheduleMenu.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxScheduleMenu.tsx new file mode 100644 index 0000000000000..a1271268c170c --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxScheduleMenu.tsx @@ -0,0 +1,19 @@ +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { useTranslation } from 'react-i18next'; + +export const useScheduleMenuItems = (): GenericMenuItemProps[] => { + const { t } = useTranslation(); + + return [ + { + id: 'schedule-new-message', + icon: 'clock', + content: t('Schedule_new_message'), + }, + { + id: 'view-scheduled-messages', + icon: 'list', + content: t('View_scheduled_messages'), + }, + ]; +}; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxScheduleModal.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxScheduleModal.tsx new file mode 100644 index 0000000000000..80fd5e94b0263 --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxScheduleModal.tsx @@ -0,0 +1,120 @@ +import type { ReactElement } from 'react'; +import { useState } from 'react'; +import { Box, InputBox, Field, FieldGroup, FieldRow, FieldLabel } from '@rocket.chat/fuselage'; +import { GenericModal } from '@rocket.chat/ui-client'; +import { useTranslation } from 'react-i18next'; + +type MessageBoxScheduleModalProps = { + onClose: () => void; + onSchedule: (scheduledAt: Date) => void; +}; + +export const MessageBoxScheduleModal = ({ onClose, onSchedule }: MessageBoxScheduleModalProps): ReactElement => { + const { t } = useTranslation(); + + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(tomorrow.getHours() + 1); + + const defaultDate = tomorrow.toISOString().split('T')[0]; + const defaultTime = tomorrow.toTimeString().slice(0, 5); + + const [date, setDate] = useState(defaultDate); + const [time, setTime] = useState(defaultTime); + const [error, setError] = useState(''); + + const handleSchedule = () => { + if (!date || !time) { + setError(t('Please_select_date_and_time')); + return; + } + + const scheduledAt = new Date(`${date}T${time}`); + const currentTime = new Date(); + + if (scheduledAt <= currentTime) { + setError(t('Scheduled_date_must_be_in_future')); + return; + } + + onSchedule(scheduledAt); + }; + + const currentDate = now.toISOString().split('T')[0]; + const currentTime = now.toTimeString().slice(0, 5); + const minDate = currentDate; + + const minTime = date === currentDate ? currentTime : undefined; + + const validateDateTime = (newDate: string, newTime: string) => { + if (!newDate || !newTime) { + setError(''); + return; + } + + const scheduledAt = new Date(`${newDate}T${newTime}`); + const currentTime = new Date(); + + if (scheduledAt <= currentTime) { + setError(t('Scheduled_date_must_be_in_future')); + } else { + setError(''); + } + }; + + return ( + + + + + {t('Date')} + + + { + const target = e.target as HTMLInputElement; + const newDate = target.value; + setDate(newDate); + validateDateTime(newDate, time); + }} + /> + + + + + {t('Time')} + + + { + const target = e.target as HTMLInputElement; + const newTime = target.value; + setTime(newTime); + validateDateTime(date, newTime); + }} + /> + + + {error && ( + + {error} + + )} + + + ); +}; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxScheduledMessagesModal.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxScheduledMessagesModal.tsx new file mode 100644 index 0000000000000..dfcadd7fdd64d --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxScheduledMessagesModal.tsx @@ -0,0 +1,116 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import type { ReactElement } from 'react'; +import { useState, useEffect, useMemo } from 'react'; +import { Box, Button, Icon, Throbber } from '@rocket.chat/fuselage'; +import { GenericModal } from '@rocket.chat/ui-client'; +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +type MessageBoxScheduledMessagesModalProps = { + roomId: string; + onClose: () => void; +}; + +export const MessageBoxScheduledMessagesModal = ({ roomId, onClose }: MessageBoxScheduledMessagesModalProps): ReactElement => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const [scheduledMessages, setScheduledMessages] = useState([]); + const [loading, setLoading] = useState(true); + + const getScheduledMessages = useEndpoint('GET', '/v1/chat.getScheduledMessages'); + const cancelScheduledMessage = useEndpoint('POST', '/v1/chat.cancelScheduledMessage'); + + const loadScheduledMessages = useMemo( + () => async () => { + try { + setLoading(true); + const result = await getScheduledMessages({ roomId }); + const messages = result.messages.map((msg: any) => ({ + ...msg, + ts: typeof msg.ts === 'string' ? new Date(msg.ts) : msg.ts, + scheduledAt: typeof msg.scheduledAt === 'string' ? new Date(msg.scheduledAt) : msg.scheduledAt, + })); + setScheduledMessages(messages); + } catch (error: any) { + dispatchToastMessage({ type: 'error', message: error.message }); + } finally { + setLoading(false); + } + }, + [roomId, getScheduledMessages, dispatchToastMessage], + ); + + useEffect(() => { + loadScheduledMessages(); + }, [loadScheduledMessages]); + + const handleCancel = async (messageId: string) => { + try { + await cancelScheduledMessage({ messageId }); + dispatchToastMessage({ type: 'success', message: t('Scheduled_message_cancelled') }); + await loadScheduledMessages(); + } catch (error: any) { + dispatchToastMessage({ type: 'error', message: error.message }); + } + }; + + const formatDate = (date: Date | string | undefined) => { + if (!date) return ''; + return new Date(date).toLocaleString(); + }; + + return ( + + {loading ? ( + + + + ) : scheduledMessages.length === 0 ? ( + + {t('No_scheduled_messages')} + + ) : ( + + {scheduledMessages.map((message) => ( + + + + {formatDate(message.scheduledAt)} + + + + {message.msg} + + + + + + ))} + + )} + + ); +}; diff --git a/apps/meteor/client/views/room/composer/messageBox/hooks/useScheduleMessageButton.tsx b/apps/meteor/client/views/room/composer/messageBox/hooks/useScheduleMessageButton.tsx new file mode 100644 index 0000000000000..c886694cdb169 --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/hooks/useScheduleMessageButton.tsx @@ -0,0 +1,89 @@ +import { useSetModal, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useCallback } from 'react'; +import { GenericMenu } from '@rocket.chat/ui-client'; +import { useTranslation } from 'react-i18next'; + +import { MessageBoxScheduleModal } from '../MessageBoxScheduleModal'; +import { MessageBoxScheduledMessagesModal } from '../MessageBoxScheduledMessagesModal'; +import { useScheduleMenuItems } from '../MessageBoxScheduleMenu'; +import { useChat } from '../../../contexts/ChatContext'; + +export const useScheduleMessageButton = (roomId: string, tmid?: string) => { + const { t } = useTranslation(); + const setModal = useSetModal(); + const dispatchToastMessage = useToastMessageDispatch(); + const scheduleMessageEndpoint = useEndpoint('POST', '/v1/chat.scheduleMessage'); + const chatContext = useChat(); + + const handleSchedule = useCallback( + async (scheduledAt: Date) => { + if (!chatContext?.composer) { + return; + } + + const text = chatContext.composer.text || ''; + if (!text.trim()) { + dispatchToastMessage({ type: 'error', message: t('Add_a_Message') }); + return; + } + + try { + await scheduleMessageEndpoint({ + roomId, + message: text, + scheduledAt: scheduledAt.toISOString(), + ...(tmid && { tmid }), + }); + + dispatchToastMessage({ type: 'success', message: t('Message_sent') }); + chatContext.composer.clear(); + setModal(null); + } catch (error: any) { + const errorMessage = error?.error || error?.message || t('Error'); + dispatchToastMessage({ type: 'error', message: errorMessage }); + } + }, + [chatContext, roomId, tmid, scheduleMessageEndpoint, dispatchToastMessage, setModal], + ); + + const openScheduleModal = useCallback(() => { + if (!chatContext?.composer?.text?.trim()) { + dispatchToastMessage({ type: 'error', message: t('Add_a_Message') }); + return; + } + setModal( setModal(null)} onSchedule={handleSchedule} />); + }, [chatContext, setModal, handleSchedule, dispatchToastMessage]); + + const openScheduledMessagesModal = useCallback(() => { + setModal( setModal(null)} />); + }, [roomId, setModal]); + + const handleMenuAction = useCallback( + (actionId: string) => { + if (actionId === 'schedule-new-message') { + openScheduleModal(); + } else if (actionId === 'view-scheduled-messages') { + openScheduledMessagesModal(); + } + }, + [openScheduleModal, openScheduledMessagesModal], + ); + + const menuItems = useScheduleMenuItems(); + const items = menuItems.map((item) => ({ + ...item, + onClick: () => handleMenuAction(item.id), + })); + + return { + scheduleMenu: ( + + ), + }; +}; diff --git a/apps/meteor/server/cron/scheduledMessages.ts b/apps/meteor/server/cron/scheduledMessages.ts new file mode 100644 index 0000000000000..3d4ee5464ffc0 --- /dev/null +++ b/apps/meteor/server/cron/scheduledMessages.ts @@ -0,0 +1,58 @@ +import { Messages, Users } from '@rocket.chat/models'; +import { cronJobs } from '@rocket.chat/cron'; + +import { sendMessage } from '../../app/lib/server/functions/sendMessage'; +import { canSendMessageAsync } from '../../app/authorization/server/functions/canSendMessage'; + +export async function processScheduledMessages(): Promise { + const now = new Date(); + + const pendingMessages = await Messages.find({ + scheduled: true, + scheduledAt: { $lte: now }, + }).toArray(); + + for (const scheduledMessage of pendingMessages) { + let messageDeleted = false; + try { + const user = await Users.findOneById(scheduledMessage.u._id); + if (!user) { + await Messages.deleteOne({ _id: scheduledMessage._id }); + continue; + } + + let room; + try { + room = await canSendMessageAsync(scheduledMessage.rid, user); + } catch (error: any) { + await Messages.deleteOne({ _id: scheduledMessage._id }); + continue; + } + + await Messages.deleteOne({ _id: scheduledMessage._id }); + messageDeleted = true; + + const message: any = { + msg: scheduledMessage.msg, + rid: scheduledMessage.rid, + ...(scheduledMessage.tmid && { tmid: scheduledMessage.tmid }), + ...(scheduledMessage.alias && { alias: scheduledMessage.alias }), + ...(scheduledMessage.avatar && { avatar: scheduledMessage.avatar }), + ...(scheduledMessage.emoji && { emoji: scheduledMessage.emoji }), + ...(scheduledMessage.attachments && { attachments: scheduledMessage.attachments }), + }; + + await sendMessage(user, message, room, {}); + } catch (error: any) { + if (!messageDeleted) { + await Messages.deleteOne({ _id: scheduledMessage._id }); + } + } + } +} + +export async function scheduledMessagesCron(): Promise { + await processScheduledMessages(); + + return cronJobs.add('Process Scheduled Messages', '* * * * *', async () => processScheduledMessages()); +} diff --git a/apps/meteor/server/startup/cron.ts b/apps/meteor/server/startup/cron.ts index bed6b624f69b2..b3262d3869042 100644 --- a/apps/meteor/server/startup/cron.ts +++ b/apps/meteor/server/startup/cron.ts @@ -2,6 +2,7 @@ import { Logger } from '@rocket.chat/logger'; import { npsCron } from '../cron/nps'; import { oembedCron } from '../cron/oembed'; +import { scheduledMessagesCron } from '../cron/scheduledMessages'; import { startCron } from '../cron/start'; import { temporaryUploadCleanupCron } from '../cron/temporaryUploadsCleanup'; import { usageReportCron } from '../cron/usageReport'; @@ -11,6 +12,14 @@ import { videoConferencesCron } from '../cron/videoConferences'; const logger = new Logger('SyncedCron'); export const startCronJobs = async (): Promise => { - await Promise.all([startCron(), oembedCron(), usageReportCron(logger), npsCron(), temporaryUploadCleanupCron(), videoConferencesCron()]); + await Promise.all([ + startCron(), + oembedCron(), + usageReportCron(logger), + npsCron(), + temporaryUploadCleanupCron(), + videoConferencesCron(), + scheduledMessagesCron(), + ]); userDataDownloadsCron(); }; diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 7a59a5b1702c8..699b333a8ad47 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -242,6 +242,8 @@ export interface IMessage extends IRocketChatRecord { // Read receipts migration flag receiptsArchived?: boolean; + scheduled?: boolean; + scheduledAt?: Date; } export type EncryptedMessageContent = Required>; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2abb096ac43b2..8faed17f0a8ac 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4844,6 +4844,20 @@ "Selecting_users": "Selecting users", "Self_managed_hosting": "Self-managed hosting", "Send": "Send", + "Schedule": "Schedule", + "Schedule_Message": "Schedule Message", + "Schedule_message": "Schedule message", + "Schedule_new_message": "Schedule new message", + "View_scheduled_messages": "View scheduled messages", + "Scheduled_Messages": "Scheduled Messages", + "Scheduled_message_cancelled": "Scheduled message cancelled", + "Scheduled_date_must_be_in_future": "Scheduled date must be in the future", + "Please_select_date_and_time": "Please select date and time", + "View_Scheduled_Messages": "View Scheduled Messages", + "No_scheduled_messages": "No scheduled messages", + "sent": "Sent", + "cancelled": "Cancelled", + "failed": "Failed", "Send_Email_SMTP_Warning": "Set up the SMTP server in email settings to enable.", "Send_anyway": "Send anyway", "Send_Test": "Send Test", diff --git a/packages/models/src/models/Messages.ts b/packages/models/src/models/Messages.ts index eaa7b08b60c54..5c008d1e23fee 100644 --- a/packages/models/src/models/Messages.ts +++ b/packages/models/src/models/Messages.ts @@ -356,6 +356,9 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { _hidden: { $ne: true, }, + scheduled: { + $ne: true, + }, rid: roomId, ts: { $lt: ts }, ...(!showThreadMessages && { @@ -757,7 +760,9 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { _hidden: { $ne: true, }, - + scheduled: { + $ne: true, + }, rid, }; @@ -797,6 +802,9 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { _hidden: { $ne: true, }, + scheduled: { + $ne: true, + }, rid: roomId, ...(!showThreadMessages && { $or: [ @@ -827,6 +835,9 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { _hidden: { $ne: true, }, + scheduled: { + $ne: true, + }, rid: roomId, ts: { $gt: timestamp, @@ -866,6 +877,9 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { _hidden: { $ne: true, }, + scheduled: { + $ne: true, + }, rid: roomId, ts: { $lt: timestamp, @@ -897,6 +911,9 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { _hidden: { $ne: true, }, + scheduled: { + $ne: true, + }, rid: roomId, ts: { [inclusive ? '$lte' : '$lt']: timestamp, @@ -933,6 +950,9 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { _hidden: { $ne: true, }, + scheduled: { + $ne: true, + }, rid: roomId, ts: { [inclusive ? '$gte' : '$gt']: afterTimestamp, diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index 8e8d52479e504..d4e75d47f8069 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -1016,4 +1016,32 @@ export type ChatEndpoints = { '/v1/chat.getURLPreview': { GET: (params: ChatGetURLPreview) => { urlPreview: MessageUrl }; }; + '/v1/chat.scheduleMessage': { + POST: (params: { + roomId: string; + message: string; + scheduledAt: string; + tmid?: string; + alias?: string; + avatar?: string; + emoji?: string; + attachments?: any[]; + customFields?: Record; + }) => { + message: IMessage; + }; + }; + '/v1/chat.getScheduledMessages': { + GET: (params: { roomId: string; count?: number; offset?: number }) => { + messages: IMessage[]; + count: number; + offset: number; + total: number; + }; + }; + '/v1/chat.cancelScheduledMessage': { + POST: (params: { messageId: string }) => { + success: boolean; + }; + }; }; diff --git a/packages/rest-typings/src/v1/chat/scheduled.ts b/packages/rest-typings/src/v1/chat/scheduled.ts new file mode 100644 index 0000000000000..446d8fe1094e8 --- /dev/null +++ b/packages/rest-typings/src/v1/chat/scheduled.ts @@ -0,0 +1,92 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type ChatScheduleMessageProps = { + roomId: string; + message: string; + scheduledAt: string; + tmid?: string; + alias?: string; + avatar?: string; + emoji?: string; + attachments?: any[]; + customFields?: Record; +}; + +const ChatScheduleMessagePropsSchema = { + type: 'object', + properties: { + roomId: { type: 'string', minLength: 1 }, + message: { type: 'string', minLength: 1 }, + scheduledAt: { type: 'string', minLength: 1 }, + tmid: { type: 'string', nullable: true }, + alias: { type: 'string', nullable: true }, + avatar: { type: 'string', nullable: true }, + emoji: { type: 'string', nullable: true }, + attachments: { type: 'array', nullable: true }, + customFields: { type: 'object', nullable: true }, + }, + required: ['roomId', 'message', 'scheduledAt'], + additionalProperties: false, +}; + +export const isChatScheduleMessageProps = ajv.compile(ChatScheduleMessagePropsSchema); + +export type ChatGetScheduledMessagesProps = { + roomId: string; + count?: number; + offset?: number; +}; + +const ChatGetScheduledMessagesPropsSchema = { + type: 'object', + properties: { + roomId: { type: 'string', minLength: 1 }, + count: { type: 'number', nullable: true }, + offset: { type: 'number', nullable: true }, + }, + required: ['roomId'], + additionalProperties: false, +}; + +export const isChatGetScheduledMessagesProps = ajv.compile(ChatGetScheduledMessagesPropsSchema); + +export type ChatCancelScheduledMessageProps = { + messageId: string; +}; + +const ChatCancelScheduledMessagePropsSchema = { + type: 'object', + properties: { + messageId: { type: 'string', minLength: 1 }, + }, + required: ['messageId'], + additionalProperties: false, +}; + +export const isChatCancelScheduledMessageProps = ajv.compile(ChatCancelScheduledMessagePropsSchema); + +export type ChatScheduledMessagesEndpoints = { + '/v1/chat.scheduleMessage': { + POST: (params: ChatScheduleMessageProps) => { + message: IMessage; + }; + }; + '/v1/chat.getScheduledMessages': { + GET: (params: ChatGetScheduledMessagesProps) => { + messages: IMessage[]; + count: number; + offset: number; + total: number; + }; + }; + '/v1/chat.cancelScheduledMessage': { + POST: (params: ChatCancelScheduledMessageProps) => { + success: boolean; + }; + }; +}; From a7929245969a9c95e9a9b308a1c055aee516200c Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Sat, 25 Apr 2026 16:35:15 +0300 Subject: [PATCH 2/7] feat/scheduled-messages --- .changeset/rare-llamas-behave.md | 74 ++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .changeset/rare-llamas-behave.md diff --git a/.changeset/rare-llamas-behave.md b/.changeset/rare-llamas-behave.md new file mode 100644 index 0000000000000..ae648f97996d4 --- /dev/null +++ b/.changeset/rare-llamas-behave.md @@ -0,0 +1,74 @@ +--- +'@rocket.chat/server-cloud-communication': minor +'@rocket.chat/omnichannel-services': minor +'rocketchat-services': minor +'@rocket.chat/omnichannel-transcript': minor +'@rocket.chat/authorization-service': minor +'@rocket.chat/federation-matrix': minor +'@rocket.chat/web-ui-registration': minor +'@rocket.chat/network-broker': minor +'@rocket.chat/password-policies': minor +'@rocket.chat/release-changelog': minor +'@rocket.chat/storybook-config': minor +'@rocket.chat/presence-service': minor +'@rocket.chat/omni-core-ee': minor +'@rocket.chat/fuselage-ui-kit': minor +'@rocket.chat/instance-status': minor +'@rocket.chat/media-signaling': minor +'@rocket.chat/patch-injection': minor +'@rocket.chat/account-service': minor +'@rocket.chat/media-calls': minor +'@rocket.chat/message-parser': minor +'@rocket.chat/mock-providers': minor +'@rocket.chat/release-action': minor +'@rocket.chat/pdf-worker': minor +'@rocket.chat/account-utils': minor +'@rocket.chat/core-services': minor +'@rocket.chat/eslint-config': minor +'@rocket.chat/message-types': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/mongo-adapter': minor +'@rocket.chat/ui-video-conf': minor +'@rocket.chat/uikit-playground': minor +'@rocket.chat/cas-validate': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/jest-presets': minor +'@rocket.chat/peggy-loader': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/server-fetch': minor +'@rocket.chat/ddp-streamer': minor +'@rocket.chat/queue-worker': minor +'@rocket.chat/presence': minor +'@rocket.chat/apps-engine': minor +'@rocket.chat/desktop-api': minor +'@rocket.chat/http-router': minor +'@rocket.chat/ui-composer': minor +'@rocket.chat/ui-contexts': minor +'@rocket.chat/license': minor +'@rocket.chat/api-client': minor +'@rocket.chat/ddp-client': minor +'@rocket.chat/log-format': minor +'@rocket.chat/gazzodown': minor +'@rocket.chat/omni-core': minor +'@rocket.chat/ui-avatar': minor +'@rocket.chat/ui-client': minor +'@rocket.chat/livechat': minor +'@rocket.chat/abac': minor +'@rocket.chat/favicon': minor +'@rocket.chat/tracing': minor +'@rocket.chat/ui-voip': minor +'@rocket.chat/agenda': minor +'@rocket.chat/logger': minor +'@rocket.chat/models': minor +'@rocket.chat/random': minor +'@rocket.chat/sha256': minor +'@rocket.chat/ui-kit': minor +'@rocket.chat/tools': minor +'@rocket.chat/apps': minor +'@rocket.chat/cron': minor +'@rocket.chat/i18n': minor +'@rocket.chat/jwt': minor +'@rocket.chat/meteor': minor +--- + +Implemented complete message scheduling functionality, allowing users to schedule messages to be sent at a specific date and time. From 02e39ac3d7df30058fcdf438a36bc89aa950c0e3 Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Sat, 25 Apr 2026 17:24:03 +0300 Subject: [PATCH 3/7] feat/scheduled-messages --- .changeset/rare-llamas-behave.md | 70 +--- .../app/api/server/v1/chat.scheduleMessage.ts | 313 ++++++++++-------- apps/meteor/server/cron/scheduledMessages.ts | 39 ++- packages/rest-typings/src/v1/chat.ts | 5 - .../rest-typings/src/v1/chat/scheduled.ts | 22 +- 5 files changed, 212 insertions(+), 237 deletions(-) diff --git a/.changeset/rare-llamas-behave.md b/.changeset/rare-llamas-behave.md index ae648f97996d4..1ef7ecfdecc53 100644 --- a/.changeset/rare-llamas-behave.md +++ b/.changeset/rare-llamas-behave.md @@ -1,74 +1,8 @@ --- -'@rocket.chat/server-cloud-communication': minor -'@rocket.chat/omnichannel-services': minor -'rocketchat-services': minor -'@rocket.chat/omnichannel-transcript': minor -'@rocket.chat/authorization-service': minor -'@rocket.chat/federation-matrix': minor -'@rocket.chat/web-ui-registration': minor -'@rocket.chat/network-broker': minor -'@rocket.chat/password-policies': minor -'@rocket.chat/release-changelog': minor -'@rocket.chat/storybook-config': minor -'@rocket.chat/presence-service': minor -'@rocket.chat/omni-core-ee': minor -'@rocket.chat/fuselage-ui-kit': minor -'@rocket.chat/instance-status': minor -'@rocket.chat/media-signaling': minor -'@rocket.chat/patch-injection': minor -'@rocket.chat/account-service': minor -'@rocket.chat/media-calls': minor -'@rocket.chat/message-parser': minor -'@rocket.chat/mock-providers': minor -'@rocket.chat/release-action': minor -'@rocket.chat/pdf-worker': minor -'@rocket.chat/account-utils': minor -'@rocket.chat/core-services': minor -'@rocket.chat/eslint-config': minor -'@rocket.chat/message-types': minor -'@rocket.chat/model-typings': minor -'@rocket.chat/mongo-adapter': minor -'@rocket.chat/ui-video-conf': minor -'@rocket.chat/uikit-playground': minor -'@rocket.chat/cas-validate': minor -'@rocket.chat/core-typings': minor -'@rocket.chat/jest-presets': minor -'@rocket.chat/peggy-loader': minor -'@rocket.chat/rest-typings': minor -'@rocket.chat/server-fetch': minor -'@rocket.chat/ddp-streamer': minor -'@rocket.chat/queue-worker': minor -'@rocket.chat/presence': minor -'@rocket.chat/apps-engine': minor -'@rocket.chat/desktop-api': minor -'@rocket.chat/http-router': minor -'@rocket.chat/ui-composer': minor -'@rocket.chat/ui-contexts': minor -'@rocket.chat/license': minor -'@rocket.chat/api-client': minor -'@rocket.chat/ddp-client': minor -'@rocket.chat/log-format': minor -'@rocket.chat/gazzodown': minor -'@rocket.chat/omni-core': minor -'@rocket.chat/ui-avatar': minor -'@rocket.chat/ui-client': minor -'@rocket.chat/livechat': minor -'@rocket.chat/abac': minor -'@rocket.chat/favicon': minor -'@rocket.chat/tracing': minor -'@rocket.chat/ui-voip': minor -'@rocket.chat/agenda': minor -'@rocket.chat/logger': minor '@rocket.chat/models': minor -'@rocket.chat/random': minor -'@rocket.chat/sha256': minor -'@rocket.chat/ui-kit': minor -'@rocket.chat/tools': minor -'@rocket.chat/apps': minor -'@rocket.chat/cron': minor -'@rocket.chat/i18n': minor -'@rocket.chat/jwt': minor '@rocket.chat/meteor': minor +'@rocket.chat/ui-composer': minor +'@rocket.chat/i18n': minor --- Implemented complete message scheduling functionality, allowing users to schedule messages to be sent at a specific date and time. diff --git a/apps/meteor/app/api/server/v1/chat.scheduleMessage.ts b/apps/meteor/app/api/server/v1/chat.scheduleMessage.ts index 60d2038f157d7..a8ab3bb62cb5f 100644 --- a/apps/meteor/app/api/server/v1/chat.scheduleMessage.ts +++ b/apps/meteor/app/api/server/v1/chat.scheduleMessage.ts @@ -1,157 +1,186 @@ -// @ts-nocheck -import { Messages, Rooms, Users } from '@rocket.chat/models'; +import type { IMessage } from '@rocket.chat/core-typings'; +import { Messages } from '@rocket.chat/models'; +import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; import { check, Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; +import { canSendMessageAsync } from '../../../authorization/server/functions/canSendMessage'; import { API } from '../api'; - -API.v1 - .post( - 'chat.scheduleMessage', - { authRequired: true }, - async function action() { - check(this.bodyParams, { - roomId: String, - message: String, - scheduledAt: String, - tmid: Match.Maybe(String), - }); - - const { roomId, message, scheduledAt, tmid } = this.bodyParams; - - if (!this.userId) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'chat.scheduleMessage' }); - } - - const room = await Rooms.findOneById(roomId); - if (!room) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'chat.scheduleMessage' }); - } - - if (!(await canAccessRoomIdAsync(roomId, this.userId))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'chat.scheduleMessage' }); - } - - const scheduledDate = new Date(scheduledAt); - if (isNaN(scheduledDate.getTime())) { - throw new Meteor.Error('error-invalid-date', 'Invalid date format', { method: 'chat.scheduleMessage' }); - } - - if (scheduledDate <= new Date()) { - throw new Meteor.Error('error-past-date', 'Scheduled date must be in the future', { method: 'chat.scheduleMessage' }); - } - - const user = await Users.findOneById(this.userId); - if (!user) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'chat.scheduleMessage' }); - } - - const messageData: any = { - rid: roomId, - msg: message, - u: { - _id: user._id, - username: user.username as string, - name: user.name, - }, - ts: new Date(), - scheduledAt: scheduledDate, - scheduled: true, - ...(tmid && { tmid }), - }; - - const result = await Messages.insertOne(messageData); - - return API.v1.success({ - message: { - ...messageData, - _id: result.insertedId, - _updatedAt: new Date(), +import { getPaginationItems } from '../helpers/getPaginationItems'; + +API.v1.post( + 'chat.scheduleMessage', + { + authRequired: true, + response: { + 200: ajv.compile<{ message: IMessage }>({ + type: 'object', + properties: { + message: { $ref: '#/components/schemas/IMessage' }, + success: { type: 'boolean', enum: [true] }, }, - }); + required: ['message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, - ) - .get( - 'chat.getScheduledMessages', - { authRequired: true }, - async function action() { - const { roomId, count = 50, offset = 0 } = this.queryParams; - - check(roomId, String); - check(count, Match.Maybe(Number)); - check(offset, Match.Maybe(Number)); - - if (!this.userId) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'chat.getScheduledMessages' }); - } - - if (!(await canAccessRoomIdAsync(roomId, this.userId))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'chat.getScheduledMessages' }); - } - - const messages = await Messages.find( - { - rid: roomId, - 'u._id': this.userId, - scheduled: true, - scheduledAt: { $exists: true }, + }, + async function action(this: any) { + check(this.bodyParams, { + roomId: String, + message: String, + scheduledAt: String, + tmid: Match.Maybe(String), + }); + + const { roomId, message, scheduledAt, tmid } = this.bodyParams as { + roomId: string; + message: string; + scheduledAt: string; + tmid?: string; + }; + + const scheduledDate = new Date(scheduledAt); + if (isNaN(scheduledDate.getTime())) { + throw new Meteor.Error('error-invalid-date', 'Invalid date format', { method: 'chat.scheduleMessage' }); + } + + if (scheduledDate <= new Date()) { + throw new Meteor.Error('error-past-date', 'Scheduled date must be in the future', { method: 'chat.scheduleMessage' }); + } + + try { + await canSendMessageAsync(roomId, this.user); + } catch (error: any) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'chat.scheduleMessage' }); + } + + const messageData = { + rid: roomId, + msg: message, + u: { + _id: this.user._id, + username: this.user.username, + name: this.user.name, + }, + ts: new Date(), + scheduledAt: scheduledDate, + scheduled: true, + ...(tmid && { tmid }), + }; + + const result = await Messages.insertOne(messageData as IMessage); + + return API.v1.success({ + message: { + ...messageData, + _id: result.insertedId, + _updatedAt: new Date(), + }, + }); + }, +); + +API.v1.get( + 'chat.getScheduledMessages', + { + authRequired: true, + response: { + 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, }, - { - sort: { scheduledAt: 1 }, - skip: Number(offset), - limit: Number(count), - }, - ).toArray(); + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action(this: any) { + const { roomId } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + + check(roomId, String); + + try { + await canSendMessageAsync(roomId, this.user); + } catch (error: any) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'chat.getScheduledMessages' }); + } - const total = await Messages.countDocuments({ + const messages = await Messages.find( + { rid: roomId, 'u._id': this.userId, scheduled: true, scheduledAt: { $exists: true }, - }); - - return API.v1.success({ - messages, - count: messages.length, - offset, - total, - }); + }, + { + sort: { scheduledAt: 1 }, + skip: Number(offset), + limit: Number(count), + }, + ).toArray(); + + const total = await Messages.countDocuments({ + rid: roomId, + 'u._id': this.userId, + scheduled: true, + scheduledAt: { $exists: true }, + }); + + return API.v1.success({ + messages, + count: messages.length, + offset, + total, + }); + }, +); + +API.v1.post( + 'chat.cancelScheduledMessage', + { + authRequired: true, + response: { + 200: ajv.compile<{ success: boolean }>({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, - ) - .post( - 'chat.cancelScheduledMessage', - { authRequired: true }, - async function action() { - check(this.bodyParams, { - messageId: String, + }, + async function action(this: any) { + check(this.bodyParams, { + messageId: String, + }); + + const { messageId } = this.bodyParams as { messageId: string }; + + const result = await Messages.deleteOne({ + _id: messageId, + 'u._id': this.userId, + scheduled: true, + }); + + if (result.deletedCount === 0) { + throw new Meteor.Error('error-message-not-found', 'Scheduled message not found or already sent', { + method: 'chat.cancelScheduledMessage', }); + } - const { messageId } = this.bodyParams; - - if (!this.userId) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'chat.cancelScheduledMessage' }); - } - - const message = await Messages.findOneById(messageId); - if (!message) { - throw new Meteor.Error('error-message-not-found', 'Scheduled message not found', { - method: 'chat.cancelScheduledMessage', - }); - } - - if (message.u._id !== this.userId) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'chat.cancelScheduledMessage' }); - } - - if (!message.scheduled) { - throw new Meteor.Error('error-not-scheduled', 'Message is not scheduled', { - method: 'chat.cancelScheduledMessage', - }); - } - - await Messages.deleteOne({ _id: messageId }); - - return API.v1.success({ success: true }); - }, - ); + return API.v1.success({ success: true }); + }, +); diff --git a/apps/meteor/server/cron/scheduledMessages.ts b/apps/meteor/server/cron/scheduledMessages.ts index 3d4ee5464ffc0..fb5f96936b17a 100644 --- a/apps/meteor/server/cron/scheduledMessages.ts +++ b/apps/meteor/server/cron/scheduledMessages.ts @@ -3,6 +3,7 @@ import { cronJobs } from '@rocket.chat/cron'; import { sendMessage } from '../../app/lib/server/functions/sendMessage'; import { canSendMessageAsync } from '../../app/authorization/server/functions/canSendMessage'; +import { SystemLogger } from '../../server/lib/logger/system'; export async function processScheduledMessages(): Promise { const now = new Date(); @@ -10,11 +11,26 @@ export async function processScheduledMessages(): Promise { const pendingMessages = await Messages.find({ scheduled: true, scheduledAt: { $lte: now }, - }).toArray(); + }) + .limit(100) + .toArray(); for (const scheduledMessage of pendingMessages) { - let messageDeleted = false; try { + const claimResult = await Messages.updateOne( + { + _id: scheduledMessage._id, + scheduled: true, + }, + { + $set: { scheduled: false }, + }, + ); + + if (claimResult.modifiedCount === 0) { + continue; + } + const user = await Users.findOneById(scheduledMessage.u._id); if (!user) { await Messages.deleteOne({ _id: scheduledMessage._id }); @@ -29,9 +45,6 @@ export async function processScheduledMessages(): Promise { continue; } - await Messages.deleteOne({ _id: scheduledMessage._id }); - messageDeleted = true; - const message: any = { msg: scheduledMessage.msg, rid: scheduledMessage.rid, @@ -43,10 +56,20 @@ export async function processScheduledMessages(): Promise { }; await sendMessage(user, message, room, {}); + await Messages.deleteOne({ _id: scheduledMessage._id }); } catch (error: any) { - if (!messageDeleted) { - await Messages.deleteOne({ _id: scheduledMessage._id }); - } + SystemLogger.error({ + msg: 'Error processing scheduled message', + err: error, + messageId: scheduledMessage._id, + roomId: scheduledMessage.rid, + userId: scheduledMessage.u._id, + }); + + await Messages.updateOne( + { _id: scheduledMessage._id }, + { $set: { scheduled: true } }, + ); } } } diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index d4e75d47f8069..068b565a2561d 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -1022,11 +1022,6 @@ export type ChatEndpoints = { message: string; scheduledAt: string; tmid?: string; - alias?: string; - avatar?: string; - emoji?: string; - attachments?: any[]; - customFields?: Record; }) => { message: IMessage; }; diff --git a/packages/rest-typings/src/v1/chat/scheduled.ts b/packages/rest-typings/src/v1/chat/scheduled.ts index 446d8fe1094e8..31dca3c2730c5 100644 --- a/packages/rest-typings/src/v1/chat/scheduled.ts +++ b/packages/rest-typings/src/v1/chat/scheduled.ts @@ -1,4 +1,4 @@ -import type { IMessage } from '@rocket.chat/core-typings'; +import type { IMessage, MessageAttachment } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; const ajv = new Ajv({ @@ -10,11 +10,8 @@ export type ChatScheduleMessageProps = { message: string; scheduledAt: string; tmid?: string; - alias?: string; - avatar?: string; - emoji?: string; - attachments?: any[]; - customFields?: Record; + attachments?: MessageAttachment[]; + customFields?: IMessage['customFields']; }; const ChatScheduleMessagePropsSchema = { @@ -23,12 +20,9 @@ const ChatScheduleMessagePropsSchema = { roomId: { type: 'string', minLength: 1 }, message: { type: 'string', minLength: 1 }, scheduledAt: { type: 'string', minLength: 1 }, - tmid: { type: 'string', nullable: true }, - alias: { type: 'string', nullable: true }, - avatar: { type: 'string', nullable: true }, - emoji: { type: 'string', nullable: true }, - attachments: { type: 'array', nullable: true }, - customFields: { type: 'object', nullable: true }, + tmid: { type: 'string' }, + attachments: { type: 'array' }, + customFields: { type: 'object' }, }, required: ['roomId', 'message', 'scheduledAt'], additionalProperties: false, @@ -46,8 +40,8 @@ const ChatGetScheduledMessagesPropsSchema = { type: 'object', properties: { roomId: { type: 'string', minLength: 1 }, - count: { type: 'number', nullable: true }, - offset: { type: 'number', nullable: true }, + count: { type: 'number' }, + offset: { type: 'number' }, }, required: ['roomId'], additionalProperties: false, From 53f9023c1b65cc64d6deffa78b859132cb4f7240 Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Sat, 25 Apr 2026 17:38:22 +0300 Subject: [PATCH 4/7] feat/scheduled-messages --- .changeset/tasty-hoops-try.md | 8 +++ .../app/api/server/v1/chat.scheduleMessage.ts | 17 +++--- apps/meteor/server/cron/scheduledMessages.ts | 52 ++++++++++++++----- 3 files changed, 56 insertions(+), 21 deletions(-) create mode 100644 .changeset/tasty-hoops-try.md diff --git a/.changeset/tasty-hoops-try.md b/.changeset/tasty-hoops-try.md new file mode 100644 index 0000000000000..ef6e5a00c463f --- /dev/null +++ b/.changeset/tasty-hoops-try.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/ui-composer': minor +'@rocket.chat/models': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Added scheduled messages diff --git a/apps/meteor/app/api/server/v1/chat.scheduleMessage.ts b/apps/meteor/app/api/server/v1/chat.scheduleMessage.ts index a8ab3bb62cb5f..539f16b484742 100644 --- a/apps/meteor/app/api/server/v1/chat.scheduleMessage.ts +++ b/apps/meteor/app/api/server/v1/chat.scheduleMessage.ts @@ -50,12 +50,15 @@ API.v1.post( throw new Meteor.Error('error-past-date', 'Scheduled date must be in the future', { method: 'chat.scheduleMessage' }); } - try { - await canSendMessageAsync(roomId, this.user); - } catch (error: any) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'chat.scheduleMessage' }); + if (tmid) { + const parentMessage = await Messages.findOneById(tmid, { projection: { rid: 1 } }); + if (!parentMessage || parentMessage.rid !== roomId) { + throw new Meteor.Error('error-invalid-tmid', 'Thread message does not belong to this room', { method: 'chat.scheduleMessage' }); + } } + await canSendMessageAsync(roomId, this.user); + const messageData = { rid: roomId, msg: message, @@ -109,11 +112,7 @@ API.v1.get( check(roomId, String); - try { - await canSendMessageAsync(roomId, this.user); - } catch (error: any) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'chat.getScheduledMessages' }); - } + await canSendMessageAsync(roomId, this.user); const messages = await Messages.find( { diff --git a/apps/meteor/server/cron/scheduledMessages.ts b/apps/meteor/server/cron/scheduledMessages.ts index fb5f96936b17a..b269de651e661 100644 --- a/apps/meteor/server/cron/scheduledMessages.ts +++ b/apps/meteor/server/cron/scheduledMessages.ts @@ -41,7 +41,16 @@ export async function processScheduledMessages(): Promise { try { room = await canSendMessageAsync(scheduledMessage.rid, user); } catch (error: any) { - await Messages.deleteOne({ _id: scheduledMessage._id }); + const isRecoverable = ['room_is_archived', 'room_is_blocked', 'You_have_been_muted', 'error-room-not-found'].includes(error.error); + + if (isRecoverable) { + await Messages.updateOne( + { _id: scheduledMessage._id }, + { $set: { scheduled: true } }, + ); + } else { + await Messages.deleteOne({ _id: scheduledMessage._id }); + } continue; } @@ -58,18 +67,37 @@ export async function processScheduledMessages(): Promise { await sendMessage(user, message, room, {}); await Messages.deleteOne({ _id: scheduledMessage._id }); } catch (error: any) { - SystemLogger.error({ - msg: 'Error processing scheduled message', - err: error, - messageId: scheduledMessage._id, - roomId: scheduledMessage.rid, - userId: scheduledMessage.u._id, - }); + const retryCount = ((scheduledMessage as any).scheduledRetryCount || 0) + 1; + const maxRetries = 5; - await Messages.updateOne( - { _id: scheduledMessage._id }, - { $set: { scheduled: true } }, - ); + if (retryCount >= maxRetries) { + SystemLogger.error({ + msg: 'Scheduled message failed after max retries', + err: error, + messageId: scheduledMessage._id, + roomId: scheduledMessage.rid, + userId: scheduledMessage.u._id, + retryCount, + }); + await Messages.deleteOne({ _id: scheduledMessage._id }); + } else { + SystemLogger.error({ + msg: 'Error processing scheduled message', + err: error, + messageId: scheduledMessage._id, + roomId: scheduledMessage.rid, + userId: scheduledMessage.u._id, + retryCount, + }); + + await Messages.updateOne( + { _id: scheduledMessage._id }, + { + $set: { scheduled: true }, + $inc: { scheduledRetryCount: 1 }, + }, + ); + } } } } From 8823ec444464e2f304025e80813a906dccbfcd56 Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Sat, 25 Apr 2026 18:33:26 +0300 Subject: [PATCH 5/7] feat/scheduled-messages --- apps/meteor/server/cron/scheduledMessages.ts | 24 ++++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/meteor/server/cron/scheduledMessages.ts b/apps/meteor/server/cron/scheduledMessages.ts index b269de651e661..9e07c3a70cfec 100644 --- a/apps/meteor/server/cron/scheduledMessages.ts +++ b/apps/meteor/server/cron/scheduledMessages.ts @@ -41,7 +41,8 @@ export async function processScheduledMessages(): Promise { try { room = await canSendMessageAsync(scheduledMessage.rid, user); } catch (error: any) { - const isRecoverable = ['room_is_archived', 'room_is_blocked', 'You_have_been_muted', 'error-room-not-found'].includes(error.error); + const errorCode = error.error || error.message; + const isRecoverable = ['room_is_archived', 'room_is_blocked', 'You_have_been_muted', 'error-invalid-room'].includes(errorCode); if (isRecoverable) { await Messages.updateOne( @@ -49,6 +50,13 @@ export async function processScheduledMessages(): Promise { { $set: { scheduled: true } }, ); } else { + SystemLogger.error({ + msg: 'Non-recoverable error for scheduled message, deleting', + err: error, + messageId: scheduledMessage._id, + roomId: scheduledMessage.rid, + userId: scheduledMessage.u._id, + }); await Messages.deleteOne({ _id: scheduledMessage._id }); } continue; @@ -64,8 +72,9 @@ export async function processScheduledMessages(): Promise { ...(scheduledMessage.attachments && { attachments: scheduledMessage.attachments }), }; - await sendMessage(user, message, room, {}); + // Delete before sending to prevent duplicate delivery on partial failure await Messages.deleteOne({ _id: scheduledMessage._id }); + await sendMessage(user, message, room, {}); } catch (error: any) { const retryCount = ((scheduledMessage as any).scheduledRetryCount || 0) + 1; const maxRetries = 5; @@ -90,10 +99,17 @@ export async function processScheduledMessages(): Promise { retryCount, }); + // Exponential backoff: 2^retryCount minutes + const delayMinutes = Math.pow(2, retryCount); + const newScheduledAt = new Date(now.getTime() + delayMinutes * 60 * 1000); + await Messages.updateOne( { _id: scheduledMessage._id }, { - $set: { scheduled: true }, + $set: { + scheduled: true, + scheduledAt: newScheduledAt, + }, $inc: { scheduledRetryCount: 1 }, }, ); @@ -103,7 +119,5 @@ export async function processScheduledMessages(): Promise { } export async function scheduledMessagesCron(): Promise { - await processScheduledMessages(); - return cronJobs.add('Process Scheduled Messages', '* * * * *', async () => processScheduledMessages()); } From 402768da2fe3b86ccfd59521632f5b5d47ed2c0c Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Sat, 25 Apr 2026 18:49:26 +0300 Subject: [PATCH 6/7] feat/scheduled-messages --- apps/meteor/server/cron/scheduledMessages.ts | 119 ++++++++++++------- 1 file changed, 77 insertions(+), 42 deletions(-) diff --git a/apps/meteor/server/cron/scheduledMessages.ts b/apps/meteor/server/cron/scheduledMessages.ts index 9e07c3a70cfec..592863b6027e2 100644 --- a/apps/meteor/server/cron/scheduledMessages.ts +++ b/apps/meteor/server/cron/scheduledMessages.ts @@ -33,7 +33,10 @@ export async function processScheduledMessages(): Promise { const user = await Users.findOneById(scheduledMessage.u._id); if (!user) { - await Messages.deleteOne({ _id: scheduledMessage._id }); + await Messages.updateOne( + { _id: scheduledMessage._id }, + { $set: { scheduled: true } }, + ); continue; } @@ -45,10 +48,34 @@ export async function processScheduledMessages(): Promise { const isRecoverable = ['room_is_archived', 'room_is_blocked', 'You_have_been_muted', 'error-invalid-room'].includes(errorCode); if (isRecoverable) { - await Messages.updateOne( - { _id: scheduledMessage._id }, - { $set: { scheduled: true } }, - ); + const retryCount = ((scheduledMessage as any).scheduledRetryCount || 0) + 1; + const maxRetries = 5; + + if (retryCount >= maxRetries) { + SystemLogger.error({ + msg: 'Recoverable error for scheduled message, max retries reached', + err: error, + messageId: scheduledMessage._id, + roomId: scheduledMessage.rid, + userId: scheduledMessage.u._id, + retryCount, + }); + await Messages.deleteOne({ _id: scheduledMessage._id }); + } else { + const delayMinutes = Math.pow(2, retryCount); + const newScheduledAt = new Date(new Date().getTime() + delayMinutes * 60 * 1000); + + await Messages.updateOne( + { _id: scheduledMessage._id }, + { + $set: { + scheduledAt: newScheduledAt, + scheduledRetryCount: retryCount, + scheduled: true, + }, + }, + ); + } } else { SystemLogger.error({ msg: 'Non-recoverable error for scheduled message, deleting', @@ -72,48 +99,56 @@ export async function processScheduledMessages(): Promise { ...(scheduledMessage.attachments && { attachments: scheduledMessage.attachments }), }; - // Delete before sending to prevent duplicate delivery on partial failure - await Messages.deleteOne({ _id: scheduledMessage._id }); - await sendMessage(user, message, room, {}); - } catch (error: any) { - const retryCount = ((scheduledMessage as any).scheduledRetryCount || 0) + 1; - const maxRetries = 5; - - if (retryCount >= maxRetries) { - SystemLogger.error({ - msg: 'Scheduled message failed after max retries', - err: error, - messageId: scheduledMessage._id, - roomId: scheduledMessage.rid, - userId: scheduledMessage.u._id, - retryCount, - }); + try { + await sendMessage(user, message, room, {}); await Messages.deleteOne({ _id: scheduledMessage._id }); - } else { - SystemLogger.error({ - msg: 'Error processing scheduled message', - err: error, - messageId: scheduledMessage._id, - roomId: scheduledMessage.rid, - userId: scheduledMessage.u._id, - retryCount, - }); + } catch (error: any) { + const retryCount = ((scheduledMessage as any).scheduledRetryCount || 0) + 1; + const maxRetries = 5; + + if (retryCount >= maxRetries) { + SystemLogger.error({ + msg: 'Scheduled message failed after max retries', + err: error, + messageId: scheduledMessage._id, + roomId: scheduledMessage.rid, + userId: scheduledMessage.u._id, + retryCount, + }); + await Messages.deleteOne({ _id: scheduledMessage._id }); + } else { + SystemLogger.error({ + msg: 'Error processing scheduled message', + err: error, + messageId: scheduledMessage._id, + roomId: scheduledMessage.rid, + userId: scheduledMessage.u._id, + retryCount, + }); - // Exponential backoff: 2^retryCount minutes - const delayMinutes = Math.pow(2, retryCount); - const newScheduledAt = new Date(now.getTime() + delayMinutes * 60 * 1000); + const delayMinutes = Math.pow(2, retryCount); + const newScheduledAt = new Date(new Date().getTime() + delayMinutes * 60 * 1000); - await Messages.updateOne( - { _id: scheduledMessage._id }, - { - $set: { - scheduled: true, - scheduledAt: newScheduledAt, + await Messages.updateOne( + { _id: scheduledMessage._id }, + { + $set: { + scheduledAt: newScheduledAt, + scheduledRetryCount: retryCount, + scheduled: true, + }, }, - $inc: { scheduledRetryCount: 1 }, - }, - ); + ); + } } + } catch (error: any) { + SystemLogger.error({ + msg: 'Unexpected error processing scheduled message', + err: error, + messageId: scheduledMessage._id, + roomId: scheduledMessage.rid, + userId: scheduledMessage.u._id, + }); } } } From 762aba9dae4d8d84abccdb9e310412aa85294d6a Mon Sep 17 00:00:00 2001 From: Polina Novak Date: Sat, 25 Apr 2026 18:56:37 +0300 Subject: [PATCH 7/7] feat/scheduled-messages --- apps/meteor/server/cron/scheduledMessages.ts | 31 +++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/meteor/server/cron/scheduledMessages.ts b/apps/meteor/server/cron/scheduledMessages.ts index 592863b6027e2..e70815fab4453 100644 --- a/apps/meteor/server/cron/scheduledMessages.ts +++ b/apps/meteor/server/cron/scheduledMessages.ts @@ -33,10 +33,33 @@ export async function processScheduledMessages(): Promise { const user = await Users.findOneById(scheduledMessage.u._id); if (!user) { - await Messages.updateOne( - { _id: scheduledMessage._id }, - { $set: { scheduled: true } }, - ); + const retryCount = ((scheduledMessage as any).scheduledRetryCount || 0) + 1; + const maxRetries = 5; + + if (retryCount >= maxRetries) { + SystemLogger.error({ + msg: 'Scheduled message user not found, max retries reached', + messageId: scheduledMessage._id, + roomId: scheduledMessage.rid, + userId: scheduledMessage.u._id, + retryCount, + }); + await Messages.deleteOne({ _id: scheduledMessage._id }); + } else { + const delayMinutes = Math.pow(2, retryCount); + const newScheduledAt = new Date(new Date().getTime() + delayMinutes * 60 * 1000); + + await Messages.updateOne( + { _id: scheduledMessage._id }, + { + $set: { + scheduledAt: newScheduledAt, + scheduledRetryCount: retryCount, + scheduled: true, + }, + }, + ); + } continue; }