diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb
index b5f516946..f0a37b01a 100644
--- a/app/builders/messages/message_builder.rb
+++ b/app/builders/messages/message_builder.rb
@@ -167,6 +167,22 @@ class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength
@params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {}
end
+ def scheduled_message_metadata
+ return {} if @params[:scheduled_message].blank?
+
+ sm = @params[:scheduled_message]
+ scheduled_by = { 'id' => sm.author_id, 'type' => sm.author_type }
+ scheduled_by['name'] = sm.author.name if sm.author.respond_to?(:name)
+
+ {
+ additional_attributes: {
+ scheduled_message_id: sm.id,
+ scheduled_by: scheduled_by,
+ scheduled_at: sm.updated_at.to_i
+ }
+ }
+ end
+
def message_sender
return if @params[:sender_type] != 'AgentBot'
@@ -192,7 +208,8 @@ class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength
is_reaction: @is_reaction,
echo_id: @params[:echo_id],
source_id: @params[:source_id]
- }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params).merge(zapi_args)
+ }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id)
+ .merge(template_params).merge(zapi_args).merge(scheduled_message_metadata)
end
def email_inbox?
diff --git a/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb b/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb
new file mode 100644
index 000000000..70c4dc7de
--- /dev/null
+++ b/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb
@@ -0,0 +1,65 @@
+class Api::V1::Accounts::Conversations::ScheduledMessagesController < Api::V1::Accounts::Conversations::BaseController
+ include Events::Types
+
+ before_action :scheduled_message, only: [:update, :destroy]
+
+ MAX_LIMIT = 100
+
+ def index
+ authorize build_scheduled_message
+ @scheduled_messages = @conversation.scheduled_messages
+ .order(scheduled_at: :desc)
+ .limit(MAX_LIMIT)
+ end
+
+ def create
+ @scheduled_message = build_scheduled_message
+ authorize @scheduled_message
+ @scheduled_message.assign_attributes(scheduled_message_params)
+ @scheduled_message.save!
+ dispatch_event(SCHEDULED_MESSAGE_CREATED, scheduled_message: @scheduled_message)
+ end
+
+ def update
+ @scheduled_message.assign_attributes(scheduled_message_params)
+ @scheduled_message.save!
+ dispatch_event(SCHEDULED_MESSAGE_UPDATED, scheduled_message: @scheduled_message)
+ end
+
+ def destroy
+ scheduled_message = @scheduled_message
+ scheduled_message.destroy!
+ dispatch_event(SCHEDULED_MESSAGE_DELETED, scheduled_message: scheduled_message)
+ rescue ActiveRecord::RecordNotDestroyed => e
+ render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
+ end
+
+ private
+
+ def scheduled_message
+ @scheduled_message ||= @conversation.scheduled_messages.find(params[:id])
+ authorize @scheduled_message
+ end
+
+ def build_scheduled_message
+ @conversation.scheduled_messages.new(account: Current.account, inbox: @conversation.inbox, author: Current.user)
+ end
+
+ def scheduled_message_params
+ params.permit(
+ :content,
+ :scheduled_at,
+ :status,
+ :attachment,
+ template_params: {}
+ )
+ end
+
+ def dispatch_event(event_name, data)
+ Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, data)
+ end
+end
+
+Api::V1::Accounts::Conversations::ScheduledMessagesController.prepend_mod_with(
+ 'Api::V1::Accounts::Conversations::ScheduledMessagesController'
+)
diff --git a/app/controllers/concerns/attachment_concern.rb b/app/controllers/concerns/attachment_concern.rb
index 2652f04be..6eae2d6f5 100644
--- a/app/controllers/concerns/attachment_concern.rb
+++ b/app/controllers/concerns/attachment_concern.rb
@@ -6,7 +6,7 @@ module AttachmentConcern
return [blobs, actions, nil] if actions.blank?
sanitized = actions.map do |action|
- next action unless action[:action_name] == 'send_attachment'
+ next action unless attachment_action?(action)
result = process_attachment_action(action, record, blobs)
return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
@@ -20,15 +20,39 @@ module AttachmentConcern
private
def process_attachment_action(action, record, blobs)
- blob_id = action[:action_params].first
+ blob_id = attachment_blob_id(action)
+ return action if action[:action_name] == 'create_scheduled_message' && blob_id.blank?
+
blob = ActiveStorage::Blob.find_signed(blob_id.to_s)
- return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present?
+ return action.merge(action_params: attachment_action_params(action, blob.id)).tap { blobs << blob } if blob.present?
return action if blob_already_attached?(record, blob_id)
nil
end
+ def attachment_action?(action)
+ %w[send_attachment create_scheduled_message].include?(action[:action_name])
+ end
+
+ def attachment_blob_id(action)
+ return action[:action_params].first unless action[:action_name] == 'create_scheduled_message'
+
+ params = action[:action_params].first
+ params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
+ params&.with_indifferent_access&.dig(:blob_id)
+ end
+
+ def attachment_action_params(action, blob_id)
+ return [blob_id] unless action[:action_name] == 'create_scheduled_message'
+
+ params = action[:action_params].first
+ params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
+ params = params.with_indifferent_access
+ params[:blob_id] = blob_id
+ [params]
+ end
+
def blob_already_attached?(record, blob_id)
record&.files&.any? { |f| f.blob_id == blob_id.to_i }
end
diff --git a/app/javascript/dashboard/api/scheduledMessages.js b/app/javascript/dashboard/api/scheduledMessages.js
new file mode 100644
index 000000000..052cb4f42
--- /dev/null
+++ b/app/javascript/dashboard/api/scheduledMessages.js
@@ -0,0 +1,66 @@
+/* global axios */
+import ApiClient from './ApiClient';
+
+export const buildScheduledMessagePayload = ({
+ content,
+ status,
+ scheduledAt,
+ templateParams,
+ attachment,
+} = {}) => {
+ if (!attachment) {
+ return {
+ content,
+ status,
+ scheduled_at: scheduledAt,
+ template_params: templateParams,
+ };
+ }
+
+ const payload = new FormData();
+ if (content) payload.append('content', content);
+ if (scheduledAt) payload.append('scheduled_at', scheduledAt);
+ if (status) payload.append('status', status);
+ payload.append('attachment', attachment);
+ if (templateParams) {
+ payload.append('template_params', JSON.stringify(templateParams));
+ }
+
+ return payload;
+};
+
+class ScheduledMessagesAPI extends ApiClient {
+ constructor() {
+ super('conversations', { accountScoped: true });
+ }
+
+ get(conversationId) {
+ return axios.get(
+ `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages`
+ );
+ }
+
+ create(conversationId, payload) {
+ return axios({
+ method: 'post',
+ url: `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages`,
+ data: buildScheduledMessagePayload(payload),
+ });
+ }
+
+ update(conversationId, scheduledMessageId, payload) {
+ return axios({
+ method: 'patch',
+ url: `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages/${scheduledMessageId}`,
+ data: buildScheduledMessagePayload(payload),
+ });
+ }
+
+ delete(conversationId, scheduledMessageId) {
+ return axios.delete(
+ `${this.baseUrl()}/conversations/${conversationId}/scheduled_messages/${scheduledMessageId}`
+ );
+ }
+}
+
+export default new ScheduledMessagesAPI();
diff --git a/app/javascript/dashboard/api/specs/scheduledMessages.spec.js b/app/javascript/dashboard/api/specs/scheduledMessages.spec.js
new file mode 100644
index 000000000..6358b478f
--- /dev/null
+++ b/app/javascript/dashboard/api/specs/scheduledMessages.spec.js
@@ -0,0 +1,77 @@
+import ScheduledMessagesAPI, {
+ buildScheduledMessagePayload,
+} from '../scheduledMessages';
+
+describe('#ScheduledMessagesAPI', () => {
+ describe('#buildScheduledMessagePayload', () => {
+ it('builds object payload without attachment or FormData with attachment', () => {
+ const objectPayload = buildScheduledMessagePayload({
+ content: 'Hello',
+ scheduledAt: '2025-01-01T10:00:00Z',
+ status: 'pending',
+ });
+
+ expect(objectPayload).toEqual({
+ content: 'Hello',
+ scheduled_at: '2025-01-01T10:00:00Z',
+ status: 'pending',
+ private: undefined,
+ template_params: undefined,
+ content_attributes: undefined,
+ additional_attributes: undefined,
+ });
+
+ const formPayload = buildScheduledMessagePayload({
+ content: 'Hello',
+ attachment: new Blob(['test'], { type: 'text/plain' }),
+ });
+
+ expect(formPayload).toBeInstanceOf(FormData);
+ expect(formPayload.get('content')).toEqual('Hello');
+ });
+ });
+
+ describe('API calls', () => {
+ const originalAxios = window.axios;
+ const originalPathname = window.location.pathname;
+ const axiosMock = Object.assign(
+ vi.fn(() => Promise.resolve()),
+ { delete: vi.fn(() => Promise.resolve()) }
+ );
+
+ beforeEach(() => {
+ axiosMock.mockClear();
+ axiosMock.delete.mockClear();
+ window.axios = axiosMock;
+ window.history.pushState({}, '', '/app/accounts/1/inbox');
+ });
+
+ afterEach(() => {
+ window.axios = originalAxios;
+ window.history.pushState({}, '', originalPathname);
+ });
+
+ it('calls correct endpoints for create, update, and delete', () => {
+ ScheduledMessagesAPI.create(12, { content: 'Hello' });
+ expect(axiosMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'post',
+ url: '/api/v1/accounts/1/conversations/12/scheduled_messages',
+ })
+ );
+
+ ScheduledMessagesAPI.update(12, 7, { status: 'pending' });
+ expect(axiosMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'patch',
+ url: '/api/v1/accounts/1/conversations/12/scheduled_messages/7',
+ })
+ );
+
+ ScheduledMessagesAPI.delete(12, 7);
+ expect(axiosMock.delete).toHaveBeenCalledWith(
+ '/api/v1/accounts/1/conversations/12/scheduled_messages/7'
+ );
+ });
+ });
+});
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue
new file mode 100644
index 000000000..05e47f5cf
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue
@@ -0,0 +1,277 @@
+
+
+
+
+
+
+
+
+
+ {{ writtenBy }}
+
+
+
+ {{ formattedScheduledTime }}
+
+
+ {{ t('SCHEDULED_MESSAGES.ITEM.NO_SCHEDULE') }}
+
+
+
+
+
+ {{ statusBadge.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ t('SCHEDULED_MESSAGES.ITEM.ATTACHMENT_LABEL', {
+ filename: attachmentName,
+ })
+ }}
+
+
+ {{
+ t('SCHEDULED_MESSAGES.ITEM.ATTACHMENT_LABEL', {
+ filename: attachmentName,
+ })
+ }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue
index 48a660e1e..6d0256beb 100644
--- a/app/javascript/dashboard/components-next/message/Message.vue
+++ b/app/javascript/dashboard/components-next/message/Message.vue
@@ -94,6 +94,7 @@ import ContextMenu from 'dashboard/modules/conversations/components/MessageConte
* @property {boolean} [isEmailInbox=false] - Whether the message is from an email inbox
* @property {number} conversationId - The ID of the conversation to which the message belongs
* @property {number} inboxId - The ID of the inbox to which the message belongs
+ * @property {Object} [additionalAttributes={}] - Additional attributes of the message
*/
// eslint-disable-next-line vue/define-macros-order
@@ -117,6 +118,8 @@ const props = defineProps({
default: 'text',
validator: value => Object.values(CONTENT_TYPES).includes(value),
},
+ // eslint-disable-next-line vue/no-unused-properties
+ additionalAttributes: { type: Object, default: () => ({}) },
conversationId: { type: Number, required: true },
createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
currentUserId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
diff --git a/app/javascript/dashboard/components-next/message/MessageMeta.vue b/app/javascript/dashboard/components-next/message/MessageMeta.vue
index 1e26c3f57..f215b15a5 100644
--- a/app/javascript/dashboard/components-next/message/MessageMeta.vue
+++ b/app/javascript/dashboard/components-next/message/MessageMeta.vue
@@ -1,6 +1,8 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/AutomationFileInput.vue b/app/javascript/dashboard/components/widgets/AutomationFileInput.vue
index 1eff406bf..ffbf999aa 100644
--- a/app/javascript/dashboard/components/widgets/AutomationFileInput.vue
+++ b/app/javascript/dashboard/components/widgets/AutomationFileInput.vue
@@ -36,7 +36,8 @@ export default {
);
this.$emit('update:modelValue', [id]);
this.uploadState = 'uploaded';
- this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADED');
+ this.label =
+ file?.name || this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADED');
} catch (error) {
this.uploadState = 'failed';
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOAD_FAILED');
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
index dc2e8ebdb..ca58c1d62 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
@@ -13,10 +13,23 @@ import AIAssistanceButton from '../AIAssistanceButton.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
+import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
+import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
+import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
+import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
export default {
name: 'ReplyBottomPanel',
- components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton },
+ components: {
+ NextButton,
+ FileUpload,
+ VideoCallButton,
+ AIAssistanceButton,
+ DropdownContainer,
+ DropdownBody,
+ DropdownSection,
+ DropdownItem,
+ },
mixins: [inboxMixin],
props: {
isNote: {
@@ -122,6 +135,10 @@ export default {
type: Boolean,
default: false,
},
+ showScheduleOptions: {
+ type: Boolean,
+ default: false,
+ },
},
emits: [
'replaceText',
@@ -129,6 +146,7 @@ export default {
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
+ 'scheduleMessage',
],
setup() {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
@@ -272,6 +290,9 @@ export default {
toggleInsertArticle() {
this.$emit('toggleInsertArticle');
},
+ openScheduleModal() {
+ this.$emit('scheduleMessage');
+ },
},
};
@@ -399,7 +420,42 @@ export default {
/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
// If the sidebar order doesn't have the new elements, then add them to the list.
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER.forEach(item => {
if (!itemsOrderCopy.find(i => i.name === item.name)) {
- itemsOrderCopy.push(item);
+ if (item.name === 'scheduled_messages') {
+ itemsOrderCopy.unshift(item);
+ } else {
+ itemsOrderCopy.push(item);
+ }
}
});
return itemsOrderCopy;
diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js
index 991576e66..78177d563 100644
--- a/app/javascript/dashboard/helper/actionCable.js
+++ b/app/javascript/dashboard/helper/actionCable.js
@@ -34,6 +34,9 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
'copilot.message.created': this.onCopilotMessageCreated,
+ 'scheduled_message.created': this.onScheduledMessageCreated,
+ 'scheduled_message.updated': this.onScheduledMessageUpdated,
+ 'scheduled_message.deleted': this.onScheduledMessageDeleted,
};
}
@@ -119,6 +122,18 @@ class ActionCableConnector extends BaseActionCableConnector {
this.fetchConversationStats();
};
+ onScheduledMessageCreated = data => {
+ this.app.$store.dispatch('handleScheduledMessageCreated', data);
+ };
+
+ onScheduledMessageUpdated = data => {
+ this.app.$store.dispatch('handleScheduledMessageUpdated', data);
+ };
+
+ onScheduledMessageDeleted = data => {
+ this.app.$store.dispatch('handleScheduledMessageDeleted', data);
+ };
+
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
diff --git a/app/javascript/dashboard/helper/actionQueryGenerator.js b/app/javascript/dashboard/helper/actionQueryGenerator.js
index 4ecae3df4..4cbfd3983 100644
--- a/app/javascript/dashboard/helper/actionQueryGenerator.js
+++ b/app/javascript/dashboard/helper/actionQueryGenerator.js
@@ -6,11 +6,19 @@ const allElementsNumbers = arr => {
return arr.every(elem => typeof elem === 'number');
};
+const allElementsPlainObjects = arr => {
+ return arr.every(
+ elem => typeof elem === 'object' && elem !== null && !elem.id
+ );
+};
+
const formatArray = params => {
if (params.length <= 0) {
params = [];
} else if (allElementsString(params) || allElementsNumbers(params)) {
params = [...params];
+ } else if (allElementsPlainObjects(params)) {
+ params = [...params];
} else {
params = params.map(val => val.id);
}
diff --git a/app/javascript/dashboard/helper/automationHelper.js b/app/javascript/dashboard/helper/automationHelper.js
index 472256156..e34773cdc 100644
--- a/app/javascript/dashboard/helper/automationHelper.js
+++ b/app/javascript/dashboard/helper/automationHelper.js
@@ -158,10 +158,21 @@ export const getConditionOptions = ({
};
export const getFileName = (action, files = []) => {
- const blobId = action.action_params[0];
+ const scheduledParams = Array.isArray(action.action_params)
+ ? action.action_params[0]
+ : action.action_params;
+ const blobId =
+ action.action_name === 'create_scheduled_message'
+ ? scheduledParams?.blob_id
+ : action.action_params?.[0];
if (!blobId) return '';
- if (action.action_name === 'send_attachment') {
- const file = files.find(item => item.blob_id === blobId);
+ if (
+ action.action_name === 'send_attachment' ||
+ action.action_name === 'create_scheduled_message'
+ ) {
+ const file = files.find(
+ item => item.blob_id?.toString() === blobId.toString()
+ );
if (file) return file.filename.toString();
}
return '';
@@ -335,7 +346,11 @@ export const getCustomAttributeType = (automationTypes, automation, key) => {
* @returns {boolean} True if the action input should be shown, false otherwise.
*/
export const showActionInput = (automationActionTypes, action) => {
- if (action === 'send_email_to_team' || action === 'send_message')
+ if (
+ action === 'send_email_to_team' ||
+ action === 'send_message' ||
+ action === 'create_scheduled_message'
+ )
return false;
const type = automationActionTypes.find(i => i.key === action)?.inputType;
return !!type;
diff --git a/app/javascript/dashboard/helper/validations.js b/app/javascript/dashboard/helper/validations.js
index edebc4656..cd96d3b62 100644
--- a/app/javascript/dashboard/helper/validations.js
+++ b/app/javascript/dashboard/helper/validations.js
@@ -136,6 +136,22 @@ const validateSingleAction = action => {
return ACTION_PARAMETERS_REQUIRED;
}
+ if (action.action_name === 'create_scheduled_message') {
+ const params = action.action_params?.[0];
+ if (!params || typeof params !== 'object') {
+ return ACTION_PARAMETERS_REQUIRED;
+ }
+ const hasContent = params.content?.trim?.();
+ const hasAttachment = params.blob_id;
+ const hasDelay = params.delay_minutes && params.delay_minutes >= 1;
+ if (!hasContent && !hasAttachment) {
+ return ACTION_PARAMETERS_REQUIRED;
+ }
+ if (!hasDelay) {
+ return ACTION_PARAMETERS_REQUIRED;
+ }
+ }
+
return null;
};
diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json
index 43245a1d5..153376f0c 100644
--- a/app/javascript/dashboard/i18n/locale/en/automation.json
+++ b/app/javascript/dashboard/i18n/locale/en/automation.json
@@ -96,7 +96,9 @@
"TEAM_MESSAGE_INPUT_PLACEHOLDER": "Enter your message here",
"TEAM_DROPDOWN_PLACEHOLDER": "Select teams",
"EMAIL_INPUT_PLACEHOLDER": "Enter email",
- "URL_INPUT_PLACEHOLDER": "Enter URL"
+ "URL_INPUT_PLACEHOLDER": "Enter URL",
+ "SCHEDULED_MESSAGE_DELAY_LABEL": "Delay (minutes)",
+ "SCHEDULED_MESSAGE_DELAY_PLACEHOLDER": "Enter delay in minutes"
},
"TOGGLE": {
"ACTIVATION_TITLE": "Activate Automation Rule",
@@ -147,6 +149,7 @@
"SEND_WEBHOOK_EVENT": "Send Webhook Event",
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
+ "CREATE_SCHEDULED_MESSAGE": "Create Scheduled Message",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA",
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index 99ecf8881..6715f951d 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -237,7 +237,8 @@
"REMOVE_PREVIEW": "Remove quoted email thread",
"COLLAPSE": "Collapse preview",
"EXPAND": "Expand preview"
- }
+ },
+ "SCHEDULE_SEND": "Schedule send"
},
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
"CHANGE_STATUS": "Conversation status changed",
@@ -363,6 +364,7 @@
},
"ACCORDION": {
"CONTACT_DETAILS": "Contact Details",
+ "SCHEDULED_MESSAGES": "Scheduled Messages",
"CONVERSATION_ACTIONS": "Conversation Actions",
"CONVERSATION_LABELS": "Conversation Labels",
"CONVERSATION_INFO": "Conversation Information",
@@ -393,6 +395,69 @@
}
}
},
+ "SCHEDULED_MESSAGES": {
+ "NEW_BUTTON": "Schedule message",
+ "PAST_MESSAGES_SECTION": "Sent",
+ "EMPTY_STATE": "There are no scheduled messages yet.",
+ "STATUS": {
+ "DRAFT": "Draft",
+ "PENDING": "Pending",
+ "SENT": "Sent",
+ "FAILED": "Failed"
+ },
+ "ITEM": {
+ "SCHEDULED_FOR": "Scheduled for {time}",
+ "NO_SCHEDULE": "No schedule",
+ "TEMPLATE_PREVIEW": "Template: {name}",
+ "ATTACHMENT_PREVIEW": "Attachment: {filename}",
+ "ATTACHMENT_LABEL": "Attachment: {filename}",
+ "EMPTY_PREVIEW": "No content",
+ "EXPAND": "Expand",
+ "COLLAPSE": "Collapse"
+ },
+ "MODAL": {
+ "TITLE_NEW": "Schedule a message",
+ "TITLE_EDIT": "Edit scheduled message",
+ "MESSAGE_LABEL": "Message",
+ "MESSAGE_PLACEHOLDER": "Write your message...",
+ "DATETIME_LABEL": "Date and time to send",
+ "DATETIME_PLACEHOLDER": "Select date and time",
+ "ATTACHMENT_LABEL": "Attachment",
+ "ATTACHMENT_ADD": "Attach file",
+ "ATTACHMENT_CURRENT": "Current attachment: {filename}",
+ "CANCEL": "Cancel",
+ "SAVE_DRAFT": "Save as draft",
+ "SCHEDULE": "Schedule"
+ },
+ "CONFIRM_CLOSE": {
+ "TITLE": "Unsaved changes",
+ "MESSAGE": "You have unsaved content. Would you like to discard your changes?",
+ "CONTINUE_EDITING": "Continue editing",
+ "DISCARD": "Discard",
+ "CANCEL": "Cancel"
+ },
+ "CONFIRM_DELETE": {
+ "TITLE": "Delete scheduled message",
+ "MESSAGE": "Are you sure you want to delete this scheduled message? This action cannot be undone.",
+ "CANCEL": "Cancel",
+ "DELETE": "Delete"
+ },
+ "META": {
+ "TOOLTIP": "Scheduled at {time} by {author}",
+ "YOU": "You",
+ "AUTHOR_YOU": "{name} (You)",
+ "AUTOMATION": "Automation",
+ "UNKNOWN_AUTHOR": "Unknown"
+ },
+ "ERRORS": {
+ "CONTENT_REQUIRED": "Add a message, template, or attachment before saving.",
+ "CONTENT_TOO_LONG": "Message is too long. Maximum {maxLength} characters allowed.",
+ "DATETIME_REQUIRED": "Select a date and time to schedule the message.",
+ "SCHEDULE_IN_PAST": "Scheduled time must be in the future.",
+ "SAVE_FAILED": "Unable to save scheduled message. Please try again.",
+ "DELETE_FAILED": "Unable to delete scheduled message. Please try again."
+ }
+ },
"CONVERSATION_CUSTOM_ATTRIBUTES": {
"ADD_BUTTON_TEXT": "Create attribute",
"NO_RECORDS_FOUND": "No attributes found",
diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/automation.json b/app/javascript/dashboard/i18n/locale/pt_BR/automation.json
index f7fd35889..6fa3e8fae 100644
--- a/app/javascript/dashboard/i18n/locale/pt_BR/automation.json
+++ b/app/javascript/dashboard/i18n/locale/pt_BR/automation.json
@@ -96,7 +96,9 @@
"TEAM_MESSAGE_INPUT_PLACEHOLDER": "Escreva sua mensagem aqui",
"TEAM_DROPDOWN_PLACEHOLDER": "Selecione times",
"EMAIL_INPUT_PLACEHOLDER": "Insira o e-mail",
- "URL_INPUT_PLACEHOLDER": "Insira a URL"
+ "URL_INPUT_PLACEHOLDER": "Insira a URL",
+ "SCHEDULED_MESSAGE_DELAY_LABEL": "Atraso (minutos)",
+ "SCHEDULED_MESSAGE_DELAY_PLACEHOLDER": "Insira o atraso em minutos"
},
"TOGGLE": {
"ACTIVATION_TITLE": "Ativar regra de automação",
@@ -147,6 +149,7 @@
"SEND_WEBHOOK_EVENT": "Enviar evento de Webhook",
"SEND_ATTACHMENT": "Enviar Anexo",
"SEND_MESSAGE": "Enviar Mensagem",
+ "CREATE_SCHEDULED_MESSAGE": "Criar Mensagem Agendada",
"ADD_PRIVATE_NOTE": "Adicionar uma Nota Privada",
"CHANGE_PRIORITY": "Alterar Prioridade",
"ADD_SLA": "Adicionar SLA",
diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json b/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json
index befaa0a58..aa65c89c7 100644
--- a/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json
@@ -237,7 +237,8 @@
"REMOVE_PREVIEW": "Remover o encadeamento de e-mails citado",
"COLLAPSE": "Recolher a prévia",
"EXPAND": "Expandir a prévia"
- }
+ },
+ "SCHEDULE_SEND": "Agendar envio"
},
"VISIBLE_TO_AGENTS": "Mensagem Privada: Apenas visível para você e seu time",
"CHANGE_STATUS": "Estado da conversa mudou",
@@ -362,6 +363,7 @@
},
"ACCORDION": {
"CONTACT_DETAILS": "Detalhes do contato",
+ "SCHEDULED_MESSAGES": "Mensagens agendadas",
"CONVERSATION_ACTIONS": "Ações da conversa",
"CONVERSATION_LABELS": "Etiquetas da conversa",
"CONVERSATION_INFO": "Informação da conversa",
@@ -392,6 +394,69 @@
}
}
},
+ "SCHEDULED_MESSAGES": {
+ "NEW_BUTTON": "Agendar mensagem",
+ "PAST_MESSAGES_SECTION": "Enviadas",
+ "EMPTY_STATE": "Ainda não há mensagens agendadas.",
+ "STATUS": {
+ "DRAFT": "Rascunho",
+ "PENDING": "Pendente",
+ "SENT": "Enviada",
+ "FAILED": "Falhou"
+ },
+ "ITEM": {
+ "SCHEDULED_FOR": "Agendada para {time}",
+ "NO_SCHEDULE": "Sem agendamento",
+ "TEMPLATE_PREVIEW": "Template: {name}",
+ "ATTACHMENT_PREVIEW": "Anexo: {filename}",
+ "ATTACHMENT_LABEL": "Anexo: {filename}",
+ "EMPTY_PREVIEW": "Sem conteúdo",
+ "EXPAND": "Expandir",
+ "COLLAPSE": "Recolher"
+ },
+ "MODAL": {
+ "TITLE_NEW": "Agendar mensagem",
+ "TITLE_EDIT": "Editar mensagem agendada",
+ "MESSAGE_LABEL": "Mensagem",
+ "MESSAGE_PLACEHOLDER": "Escreva sua mensagem...",
+ "DATETIME_LABEL": "Data e hora de envio",
+ "DATETIME_PLACEHOLDER": "Selecione data e hora",
+ "ATTACHMENT_LABEL": "Anexo",
+ "ATTACHMENT_ADD": "Anexar arquivo",
+ "ATTACHMENT_CURRENT": "Anexo atual: {filename}",
+ "CANCEL": "Cancelar",
+ "SAVE_DRAFT": "Salvar como rascunho",
+ "SCHEDULE": "Agendar"
+ },
+ "CONFIRM_CLOSE": {
+ "TITLE": "Alterações não salvas",
+ "MESSAGE": "Você tem conteúdo não salvo. Deseja descartar suas alterações?",
+ "CONTINUE_EDITING": "Continuar editando",
+ "DISCARD": "Descartar",
+ "CANCEL": "Cancelar"
+ },
+ "CONFIRM_DELETE": {
+ "TITLE": "Excluir mensagem agendada",
+ "MESSAGE": "Tem certeza de que deseja excluir esta mensagem agendada? Esta ação não pode ser desfeita.",
+ "CANCEL": "Cancelar",
+ "DELETE": "Excluir"
+ },
+ "ERRORS": {
+ "CONTENT_REQUIRED": "Adicione uma mensagem, template ou anexo antes de salvar.",
+ "CONTENT_TOO_LONG": "A mensagem é muito longa. Máximo de {maxLength} caracteres permitidos.",
+ "DATETIME_REQUIRED": "Selecione uma data e hora para agendar a mensagem.",
+ "SCHEDULE_IN_PAST": "O horário agendado deve ser no futuro.",
+ "SAVE_FAILED": "Não foi possível salvar a mensagem agendada. Por favor, tente novamente.",
+ "DELETE_FAILED": "Não foi possível excluir a mensagem agendada. Por favor, tente novamente."
+ },
+ "META": {
+ "TOOLTIP": "Agendada em {time} por {author}",
+ "YOU": "Você",
+ "AUTHOR_YOU": "{name} (Você)",
+ "AUTOMATION": "Automação",
+ "UNKNOWN_AUTHOR": "Desconhecido"
+ }
+ },
"CONVERSATION_CUSTOM_ATTRIBUTES": {
"ADD_BUTTON_TEXT": "Criar atributo",
"NO_RECORDS_FOUND": "Nenhum atributo encontrado",
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
index b163bfdc8..c148625fa 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
@@ -15,6 +15,7 @@ import ConversationAction from './ConversationAction.vue';
import ConversationParticipant from './ConversationParticipant.vue';
import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
+import ScheduledMessages from './scheduledMessages/ScheduledMessages.vue';
import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import Draggable from 'vuedraggable';
@@ -150,7 +151,26 @@ onMounted(() => {
>
+
+ toggleSidebarUIState('is_scheduled_messages_open', value)
+ "
+ >
+
+
+
+
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import DatePicker from 'vue-datepicker-next';
+import FileUpload from 'vue-upload-component';
+
+import { useAlert } from 'dashboard/composables';
+import { useStore, useMapGetter } from 'dashboard/composables/store';
+import { useFileUpload } from 'dashboard/composables/useFileUpload';
+import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
+import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
+
+import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
+import NextButton from 'dashboard/components-next/button/Button.vue';
+import AttachmentPreviews from 'dashboard/components-next/NewConversation/components/AttachmentPreviews.vue';
+import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
+import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
+import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
+import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
+
+const props = defineProps({
+ show: {
+ type: Boolean,
+ default: false,
+ },
+ conversationId: {
+ type: [Number, String],
+ required: true,
+ },
+ inboxId: {
+ type: [Number, String],
+ default: null,
+ },
+ scheduledMessage: {
+ type: Object,
+ default: null,
+ },
+ initialContent: {
+ type: String,
+ default: '',
+ },
+ initialAttachment: {
+ type: Object,
+ default: null,
+ },
+});
+
+const emit = defineEmits(['update:show', 'close', 'scheduledMessageCreated']);
+
+const { t } = useI18n();
+const store = useStore();
+
+const inboxGetter = useMapGetter('inboxes/getInbox');
+const uiFlags = useMapGetter('scheduledMessages/getUIFlags');
+
+const isEditing = computed(() => !!props.scheduledMessage?.id);
+const isCreating = computed(() => uiFlags.value.isCreating);
+const isUpdating = computed(() => uiFlags.value.isUpdating);
+const isSubmitting = computed(() => isCreating.value || isUpdating.value);
+const currentInbox = computed(() => inboxGetter.value(props.inboxId));
+
+const messageContent = ref('');
+const scheduledDateTime = ref(null);
+const attachments = ref([]);
+const existingAttachment = ref(null);
+const templateParams = ref(null);
+const showConfirmClose = ref(false);
+const datePickerOpen = ref(false);
+const contentError = ref(false);
+const contentLengthError = ref(false);
+const dateTimeError = ref('');
+
+// Original values for change detection
+const originalContent = ref('');
+const originalScheduledAt = ref(null);
+const originalHasAttachment = ref(false);
+
+// NOTE: Local ref to control modal visibility, prevents auto-close when unsaved changes exist
+const localShowModal = ref(false);
+
+const datePickerLang = {
+ days: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
+ months: [
+ 'January',
+ 'February',
+ 'March',
+ 'April',
+ 'May',
+ 'June',
+ 'July',
+ 'August',
+ 'September',
+ 'October',
+ 'November',
+ 'December',
+ ],
+ yearFormat: 'YYYY',
+ monthFormat: 'MMMM',
+};
+
+const resetForm = () => {
+ messageContent.value = '';
+ scheduledDateTime.value = null;
+ attachments.value = [];
+ existingAttachment.value = null;
+ templateParams.value = null;
+ contentError.value = false;
+ dateTimeError.value = '';
+ // Reset original values
+ originalContent.value = '';
+ originalScheduledAt.value = null;
+ originalHasAttachment.value = false;
+};
+
+const setFormFromMessage = scheduledMessage => {
+ if (!scheduledMessage) {
+ resetForm();
+ return;
+ }
+
+ messageContent.value = scheduledMessage.content || '';
+ templateParams.value = scheduledMessage.template_params || null;
+ existingAttachment.value = scheduledMessage.attachment || null;
+ attachments.value = [];
+
+ if (scheduledMessage.scheduled_at) {
+ const dateValue = new Date(scheduledMessage.scheduled_at * 1000);
+ dateValue.setSeconds(0, 0);
+ scheduledDateTime.value = dateValue;
+ } else {
+ scheduledDateTime.value = null;
+ }
+
+ // Store original values for change detection
+ originalContent.value = messageContent.value?.trim() || '';
+ originalScheduledAt.value = scheduledDateTime.value
+ ? new Date(scheduledDateTime.value)
+ : null;
+ originalHasAttachment.value = !!existingAttachment.value;
+};
+
+const { onFileUpload } = useFileUpload({
+ inbox: currentInbox.value || {},
+ attachFile: ({ blob, file }) => {
+ if (!file) return;
+ const reader = new FileReader();
+ reader.readAsDataURL(file.file);
+ reader.onloadend = () => {
+ attachments.value = [
+ {
+ resource: blob || file,
+ thumb: reader.result,
+ blobSignedId: blob?.signed_id,
+ },
+ ];
+ };
+ },
+});
+
+const scheduledAt = computed(() => {
+ if (!scheduledDateTime.value) return null;
+
+ const date = new Date(scheduledDateTime.value);
+ date.setSeconds(0, 0);
+
+ return date;
+});
+
+const hasContent = computed(() => Boolean(messageContent.value?.trim()));
+const hasNewAttachment = computed(() => attachments.value.length > 0);
+const hasTemplate = computed(
+ () => templateParams.value && Object.keys(templateParams.value).length
+);
+const hasExistingAttachment = computed(() => !!existingAttachment.value);
+const showAttachmentUpload = computed(() => !hasNewAttachment.value);
+
+const maxLength = computed(() => {
+ const channelType = currentInbox.value?.channel_type;
+ const medium = currentInbox.value?.medium;
+
+ if (
+ channelType === 'Channel::FacebookPage' &&
+ medium === 'instagram_direct_message'
+ ) {
+ return MESSAGE_MAX_LENGTH.INSTAGRAM;
+ }
+ if (channelType === 'Channel::FacebookPage') {
+ return MESSAGE_MAX_LENGTH.FACEBOOK;
+ }
+ if (channelType === 'Channel::TwilioSms' && medium === 'whatsapp') {
+ return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
+ }
+ if (channelType === 'Channel::Whatsapp') {
+ return MESSAGE_MAX_LENGTH.WHATSAPP_CLOUD;
+ }
+ if (channelType === 'Channel::Sms') {
+ return MESSAGE_MAX_LENGTH.TWILIO_SMS;
+ }
+ if (channelType === 'Channel::TwilioSms' && medium === 'sms') {
+ return MESSAGE_MAX_LENGTH.TWILIO_SMS;
+ }
+ if (channelType === 'Channel::Email') {
+ return MESSAGE_MAX_LENGTH.EMAIL;
+ }
+ if (channelType === 'Channel::Telegram') {
+ return MESSAGE_MAX_LENGTH.TELEGRAM;
+ }
+ if (channelType === 'Channel::Line') {
+ return MESSAGE_MAX_LENGTH.LINE;
+ }
+ if (channelType === 'Channel::Tiktok') {
+ return MESSAGE_MAX_LENGTH.TIKTOK;
+ }
+ return MESSAGE_MAX_LENGTH.GENERAL;
+});
+
+const isContentTooLong = computed(
+ () => messageContent.value?.length > maxLength.value
+);
+
+const hasUnsavedChanges = computed(() => {
+ const contentChanged = messageContent.value?.trim() !== originalContent.value;
+ const dateChanged =
+ scheduledDateTime.value?.getTime() !== originalScheduledAt.value?.getTime();
+ const attachmentChanged =
+ hasNewAttachment.value ||
+ (originalHasAttachment.value && !hasExistingAttachment.value);
+
+ return contentChanged || dateChanged || attachmentChanged;
+});
+
+const showModal = computed({
+ get: () => localShowModal.value,
+ set: value => {
+ // NOTE: When trying to close the modal, check for unsaved changes first
+ if (!value && hasUnsavedChanges.value && !showConfirmClose.value) {
+ showConfirmClose.value = true;
+ return;
+ }
+ localShowModal.value = value;
+ if (!value) {
+ emit('update:show', false);
+ }
+ },
+});
+
+watch(
+ () => props.show,
+ newValue => {
+ if (newValue) {
+ localShowModal.value = true;
+ }
+ },
+ { immediate: true }
+);
+
+const disablePastDates = date => {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ return date < today;
+};
+
+const onDateTimeChange = value => {
+ scheduledDateTime.value = value;
+ dateTimeError.value = '';
+};
+
+const closeDatePicker = () => {
+ datePickerOpen.value = false;
+};
+
+const onAttachmentsChange = value => {
+ attachments.value = value.slice(0, 1);
+};
+
+const resolveAttachmentPayload = () => {
+ if (!attachments.value.length) return null;
+ const attachment = attachments.value[0];
+ return (
+ attachment.blobSignedId ||
+ attachment.resource?.signed_id ||
+ attachment.resource?.file ||
+ attachment.resource
+ );
+};
+
+const isFutureSchedule = date => {
+ if (!date) return false;
+ const scheduled = new Date(date);
+ const now = new Date();
+ return scheduled > now;
+};
+
+const validatePayload = status => {
+ contentError.value = false;
+ contentLengthError.value = false;
+ dateTimeError.value = null;
+
+ const hasPayloadContent =
+ hasContent.value ||
+ hasTemplate.value ||
+ hasExistingAttachment.value ||
+ hasNewAttachment.value;
+
+ if (!hasPayloadContent) {
+ contentError.value = true;
+ return false;
+ }
+
+ if (isContentTooLong.value) {
+ contentLengthError.value = true;
+ return false;
+ }
+
+ if (status === 'pending') {
+ if (!scheduledAt.value) {
+ dateTimeError.value = t('SCHEDULED_MESSAGES.ERRORS.DATETIME_REQUIRED');
+ return false;
+ }
+ if (!isFutureSchedule(scheduledAt.value)) {
+ dateTimeError.value = t('SCHEDULED_MESSAGES.ERRORS.SCHEDULE_IN_PAST');
+ return false;
+ }
+ }
+
+ return true;
+};
+
+const buildPayload = status => {
+ const payload = {
+ content: messageContent.value,
+ status,
+ scheduledAt: scheduledAt.value ? scheduledAt.value.toISOString() : null,
+ private: false,
+ };
+
+ if (templateParams.value && Object.keys(templateParams.value).length) {
+ payload.templateParams = templateParams.value;
+ }
+
+ const attachmentPayload = resolveAttachmentPayload();
+ if (attachmentPayload) {
+ payload.attachment = attachmentPayload;
+ }
+
+ return payload;
+};
+
+const closeModal = () => {
+ showConfirmClose.value = false;
+ localShowModal.value = false;
+ emit('update:show', false);
+ emit('close');
+ resetForm();
+};
+
+const submit = async status => {
+ if (!validatePayload(status)) return;
+
+ try {
+ if (isEditing.value) {
+ await store.dispatch('scheduledMessages/update', {
+ conversationId: props.conversationId,
+ scheduledMessageId: props.scheduledMessage.id,
+ payload: buildPayload(status),
+ });
+ } else {
+ await store.dispatch('scheduledMessages/create', {
+ conversationId: props.conversationId,
+ payload: buildPayload(status),
+ });
+ }
+
+ if (status === 'pending') {
+ emit('scheduledMessageCreated');
+ }
+ closeModal();
+ } catch (error) {
+ useAlert(t('SCHEDULED_MESSAGES.ERRORS.SAVE_FAILED'));
+ }
+};
+
+const handleClose = () => {
+ if (hasUnsavedChanges.value) {
+ showConfirmClose.value = true;
+ return;
+ }
+ closeModal();
+};
+
+const handleContinueEditing = () => {
+ showConfirmClose.value = false;
+};
+
+const handleConfirmDiscard = () => {
+ showConfirmClose.value = false;
+ closeModal();
+};
+
+watch(
+ () => props.show,
+ isVisible => {
+ if (isVisible) {
+ if (props.scheduledMessage) {
+ setFormFromMessage(props.scheduledMessage);
+ } else {
+ resetForm();
+ if (props.initialContent) {
+ messageContent.value = props.initialContent;
+ }
+ if (props.initialAttachment) {
+ attachments.value = [
+ {
+ resource: props.initialAttachment.resource,
+ thumb: props.initialAttachment.thumb,
+ blobSignedId: props.initialAttachment.blobSignedId,
+ },
+ ];
+ }
+ }
+ } else {
+ resetForm();
+ }
+ }
+);
+
+watch(
+ () => props.scheduledMessage,
+ newMessage => {
+ if (props.show) {
+ setFormFromMessage(newMessage);
+ }
+ }
+);
+
+
+
+
+
+
+ {{
+ isEditing
+ ? t('SCHEDULED_MESSAGES.MODAL.TITLE_EDIT')
+ : t('SCHEDULED_MESSAGES.MODAL.TITLE_NEW')
+ }}
+
+
+
+
+ {{ t('SCHEDULED_MESSAGES.MODAL.MESSAGE_LABEL') }}
+
+ {
+ contentError = false;
+ contentLengthError = false;
+ }
+ "
+ />
+
+ {{ t('SCHEDULED_MESSAGES.ERRORS.CONTENT_REQUIRED') }}
+
+
+ {{
+ t('SCHEDULED_MESSAGES.ERRORS.CONTENT_TOO_LONG', {
+ maxLength: maxLength,
+ })
+ }}
+
+
+
+
+
+ {{ t('SCHEDULED_MESSAGES.MODAL.DATETIME_LABEL') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ t('SCHEDULED_MESSAGES.MODAL.ATTACHMENT_CURRENT', {
+ filename: existingAttachment.filename,
+ })
+ }}
+
+
+
+
+ {{ dateTimeError }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('SCHEDULED_MESSAGES.CONFIRM_CLOSE.TITLE') }}
+
+
+ {{ t('SCHEDULED_MESSAGES.CONFIRM_CLOSE.MESSAGE') }}
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessageSkeletonLoader.vue b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessageSkeletonLoader.vue
new file mode 100644
index 000000000..1bcfea003
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessageSkeletonLoader.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessages.vue b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessages.vue
new file mode 100644
index 000000000..5abab8028
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessages.vue
@@ -0,0 +1,253 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('SCHEDULED_MESSAGES.EMPTY_STATE') }}
+
+
+
+
+
+
+ {{ t('SCHEDULED_MESSAGES.PAST_MESSAGES_SECTION') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('SCHEDULED_MESSAGES.CONFIRM_DELETE.TITLE') }}
+
+
+ {{ t('SCHEDULED_MESSAGES.CONFIRM_DELETE.MESSAGE') }}
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js
index bc767040b..3fac7e699 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js
@@ -100,6 +100,10 @@ export const AUTOMATIONS = {
key: 'send_message',
name: 'SEND_MESSAGE',
},
+ {
+ key: 'create_scheduled_message',
+ name: 'CREATE_SCHEDULED_MESSAGE',
+ },
{
key: 'send_email_transcript',
name: 'SEND_EMAIL_TRANSCRIPT',
@@ -220,6 +224,10 @@ export const AUTOMATIONS = {
key: 'send_message',
name: 'SEND_MESSAGE',
},
+ {
+ key: 'create_scheduled_message',
+ name: 'CREATE_SCHEDULED_MESSAGE',
+ },
{
key: 'send_email_transcript',
name: 'SEND_EMAIL_TRANSCRIPT',
@@ -348,6 +356,10 @@ export const AUTOMATIONS = {
key: 'send_message',
name: 'SEND_MESSAGE',
},
+ {
+ key: 'create_scheduled_message',
+ name: 'CREATE_SCHEDULED_MESSAGE',
+ },
{
key: 'send_email_transcript',
name: 'SEND_EMAIL_TRANSCRIPT',
@@ -470,6 +482,10 @@ export const AUTOMATIONS = {
key: 'send_message',
name: 'SEND_MESSAGE',
},
+ {
+ key: 'create_scheduled_message',
+ name: 'CREATE_SCHEDULED_MESSAGE',
+ },
{
key: 'send_email_transcript',
name: 'SEND_EMAIL_TRANSCRIPT',
@@ -578,6 +594,10 @@ export const AUTOMATIONS = {
key: 'send_message',
name: 'SEND_MESSAGE',
},
+ {
+ key: 'create_scheduled_message',
+ name: 'CREATE_SCHEDULED_MESSAGE',
+ },
{
key: 'send_email_transcript',
name: 'SEND_EMAIL_TRANSCRIPT',
@@ -683,6 +703,11 @@ export const AUTOMATION_ACTION_TYPES = [
label: 'SEND_MESSAGE',
inputType: 'textarea',
},
+ {
+ key: 'create_scheduled_message',
+ label: 'CREATE_SCHEDULED_MESSAGE',
+ inputType: 'scheduled_message',
+ },
{
key: 'add_private_note',
label: 'ADD_PRIVATE_NOTE',
diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js
index d56958eb5..1b150c916 100755
--- a/app/javascript/dashboard/store/index.js
+++ b/app/javascript/dashboard/store/index.js
@@ -41,6 +41,7 @@ import macros from './modules/macros';
import notifications from './modules/notifications';
import portals from './modules/helpCenterPortals';
import reports from './modules/reports';
+import scheduledMessages from './modules/scheduledMessages';
import sla from './modules/sla';
import slaReports from './modules/SLAReports';
import summaryReports from './modules/summaryReports';
@@ -104,6 +105,7 @@ export default createStore({
notifications,
portals,
reports,
+ scheduledMessages,
sla,
slaReports,
summaryReports,
diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js
index b0a4e591c..eeca05983 100644
--- a/app/javascript/dashboard/store/modules/conversations/actions.js
+++ b/app/javascript/dashboard/store/modules/conversations/actions.js
@@ -436,6 +436,18 @@ const actions = {
commit(types.UPDATE_CONVERSATION_CONTACT, data);
},
+ handleScheduledMessageCreated({ dispatch }, scheduledMessage) {
+ dispatch('scheduledMessages/upsertFromEvent', scheduledMessage);
+ },
+
+ handleScheduledMessageUpdated({ dispatch }, scheduledMessage) {
+ dispatch('scheduledMessages/upsertFromEvent', scheduledMessage);
+ },
+
+ handleScheduledMessageDeleted({ dispatch }, scheduledMessage) {
+ dispatch('scheduledMessages/removeFromEvent', scheduledMessage);
+ },
+
setActiveInbox({ commit }, inboxId) {
commit(types.SET_ACTIVE_INBOX, inboxId);
},
diff --git a/app/javascript/dashboard/store/modules/scheduledMessages.js b/app/javascript/dashboard/store/modules/scheduledMessages.js
new file mode 100644
index 000000000..2cb28605a
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/scheduledMessages.js
@@ -0,0 +1,189 @@
+import types from '../mutation-types';
+import ScheduledMessagesAPI from '../../api/scheduledMessages';
+
+export const state = {
+ records: {},
+ uiFlags: {
+ isFetching: false,
+ isCreating: false,
+ isUpdating: false,
+ isDeleting: false,
+ },
+};
+
+export const getters = {
+ getAllByConversation: _state => conversationId => {
+ return _state.records[Number(conversationId)] || [];
+ },
+ getUIFlags(_state) {
+ return _state.uiFlags;
+ },
+};
+
+export const actions = {
+ async get({ commit }, { conversationId }) {
+ commit(types.SET_SCHEDULED_MESSAGES_UI_FLAG, { isFetching: true });
+ try {
+ const normalizedConversationId = Number(conversationId);
+ const { data } = await ScheduledMessagesAPI.get(normalizedConversationId);
+ commit(types.SET_SCHEDULED_MESSAGES, {
+ conversationId: normalizedConversationId,
+ data: data.payload,
+ });
+ } catch (error) {
+ throw new Error(error);
+ } finally {
+ commit(types.SET_SCHEDULED_MESSAGES_UI_FLAG, { isFetching: false });
+ }
+ },
+
+ async create({ commit }, { conversationId, payload }) {
+ commit(types.SET_SCHEDULED_MESSAGES_UI_FLAG, { isCreating: true });
+ try {
+ const normalizedConversationId = Number(conversationId);
+ const { data } = await ScheduledMessagesAPI.create(
+ normalizedConversationId,
+ payload
+ );
+ commit(types.ADD_SCHEDULED_MESSAGE, {
+ conversationId: normalizedConversationId,
+ data,
+ });
+ return data;
+ } catch (error) {
+ throw new Error(error);
+ } finally {
+ commit(types.SET_SCHEDULED_MESSAGES_UI_FLAG, { isCreating: false });
+ }
+ },
+
+ async update({ commit }, { conversationId, scheduledMessageId, payload }) {
+ commit(types.SET_SCHEDULED_MESSAGES_UI_FLAG, { isUpdating: true });
+ try {
+ const normalizedConversationId = Number(conversationId);
+ const { data } = await ScheduledMessagesAPI.update(
+ normalizedConversationId,
+ scheduledMessageId,
+ payload
+ );
+ commit(types.UPDATE_SCHEDULED_MESSAGE, {
+ conversationId: normalizedConversationId,
+ data,
+ });
+ return data;
+ } catch (error) {
+ throw new Error(error);
+ } finally {
+ commit(types.SET_SCHEDULED_MESSAGES_UI_FLAG, { isUpdating: false });
+ }
+ },
+
+ async delete({ commit }, { conversationId, scheduledMessageId }) {
+ commit(types.SET_SCHEDULED_MESSAGES_UI_FLAG, { isDeleting: true });
+ try {
+ const normalizedConversationId = Number(conversationId);
+ await ScheduledMessagesAPI.delete(
+ normalizedConversationId,
+ scheduledMessageId
+ );
+ commit(types.DELETE_SCHEDULED_MESSAGE, {
+ conversationId: normalizedConversationId,
+ scheduledMessageId,
+ });
+ } catch (error) {
+ throw new Error(error);
+ } finally {
+ commit(types.SET_SCHEDULED_MESSAGES_UI_FLAG, { isDeleting: false });
+ }
+ },
+
+ upsertFromEvent({ commit, state: localState }, scheduledMessage) {
+ const conversationId = Number(scheduledMessage.conversation_id);
+ const records = localState.records[conversationId] || [];
+ const exists = records.some(record => record.id === scheduledMessage.id);
+
+ commit(
+ exists ? types.UPDATE_SCHEDULED_MESSAGE : types.ADD_SCHEDULED_MESSAGE,
+ { conversationId, data: scheduledMessage }
+ );
+ },
+
+ removeFromEvent({ commit }, scheduledMessage) {
+ commit(types.DELETE_SCHEDULED_MESSAGE, {
+ conversationId: Number(scheduledMessage.conversation_id),
+ scheduledMessageId: scheduledMessage.id,
+ });
+ },
+};
+
+export const mutations = {
+ [types.SET_SCHEDULED_MESSAGES_UI_FLAG]($state, data) {
+ $state.uiFlags = {
+ ...$state.uiFlags,
+ ...data,
+ };
+ },
+
+ [types.SET_SCHEDULED_MESSAGES]($state, { conversationId, data }) {
+ $state.records = {
+ ...$state.records,
+ [Number(conversationId)]: data,
+ };
+ },
+
+ [types.ADD_SCHEDULED_MESSAGE]($state, { conversationId, data }) {
+ const normalizedConversationId = Number(conversationId);
+ const records = $state.records[normalizedConversationId] || [];
+ const existingIndex = records.findIndex(record => record.id === data.id);
+
+ if (existingIndex > -1) {
+ records[existingIndex] = data;
+ } else {
+ records.push(data);
+ }
+
+ $state.records = {
+ ...$state.records,
+ [normalizedConversationId]: [...records],
+ };
+ },
+
+ [types.UPDATE_SCHEDULED_MESSAGE]($state, { conversationId, data }) {
+ const normalizedConversationId = Number(conversationId);
+ const records = $state.records[normalizedConversationId] || [];
+ const existingIndex = records.findIndex(record => record.id === data.id);
+
+ if (existingIndex > -1) {
+ records[existingIndex] = data;
+ } else {
+ records.push(data);
+ }
+
+ $state.records = {
+ ...$state.records,
+ [normalizedConversationId]: [...records],
+ };
+ },
+
+ [types.DELETE_SCHEDULED_MESSAGE](
+ $state,
+ { conversationId, scheduledMessageId }
+ ) {
+ const normalizedConversationId = Number(conversationId);
+ const records = $state.records[normalizedConversationId] || [];
+ $state.records = {
+ ...$state.records,
+ [normalizedConversationId]: records.filter(
+ record => record.id !== scheduledMessageId
+ ),
+ };
+ },
+};
+
+export default {
+ namespaced: true,
+ state,
+ getters,
+ actions,
+ mutations,
+};
diff --git a/app/javascript/dashboard/store/modules/specs/scheduledMessages/actions.spec.js b/app/javascript/dashboard/store/modules/specs/scheduledMessages/actions.spec.js
new file mode 100644
index 000000000..81a6d1805
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/scheduledMessages/actions.spec.js
@@ -0,0 +1,130 @@
+import { actions } from '../../scheduledMessages';
+import * as types from '../../../mutation-types';
+import ScheduledMessagesAPI from '../../../../api/scheduledMessages';
+
+describe('#scheduledMessages actions', () => {
+ describe('#get', () => {
+ it('fetches and sets all scheduled messages for a conversation', async () => {
+ const commit = vi.fn();
+ vi.spyOn(ScheduledMessagesAPI, 'get').mockResolvedValue({
+ data: {
+ payload: [{ id: 1 }, { id: 2 }],
+ },
+ });
+
+ await actions.get({ commit }, { conversationId: '12' });
+
+ expect(commit).toHaveBeenCalledWith(
+ types.default.SET_SCHEDULED_MESSAGES_UI_FLAG,
+ { isFetching: true }
+ );
+ expect(commit).toHaveBeenCalledWith(
+ types.default.SET_SCHEDULED_MESSAGES,
+ { conversationId: 12, data: [{ id: 1 }, { id: 2 }] }
+ );
+ expect(commit).toHaveBeenCalledWith(
+ types.default.SET_SCHEDULED_MESSAGES_UI_FLAG,
+ { isFetching: false }
+ );
+ });
+ });
+
+ describe('#create', () => {
+ it('commits ADD_SCHEDULED_MESSAGE and returns created data', async () => {
+ const commit = vi.fn();
+ vi.spyOn(ScheduledMessagesAPI, 'create').mockResolvedValue({
+ data: { id: 9 },
+ });
+
+ const result = await actions.create(
+ { commit },
+ { conversationId: '7', payload: { content: 'Hello' } }
+ );
+
+ expect(result).toEqual({ id: 9 });
+ expect(commit).toHaveBeenCalledWith(types.default.ADD_SCHEDULED_MESSAGE, {
+ conversationId: 7,
+ data: { id: 9 },
+ });
+ });
+ });
+
+ describe('#update', () => {
+ it('commits UPDATE_SCHEDULED_MESSAGE and returns updated data', async () => {
+ const commit = vi.fn();
+ vi.spyOn(ScheduledMessagesAPI, 'update').mockResolvedValue({
+ data: { id: 9, status: 'pending' },
+ });
+
+ const result = await actions.update(
+ { commit },
+ { conversationId: '7', scheduledMessageId: 3, payload: {} }
+ );
+
+ expect(result).toEqual({ id: 9, status: 'pending' });
+ expect(commit).toHaveBeenCalledWith(
+ types.default.UPDATE_SCHEDULED_MESSAGE,
+ { conversationId: 7, data: { id: 9, status: 'pending' } }
+ );
+ });
+ });
+
+ describe('#delete', () => {
+ it('commits DELETE_SCHEDULED_MESSAGE', async () => {
+ const commit = vi.fn();
+ vi.spyOn(ScheduledMessagesAPI, 'delete').mockResolvedValue({});
+
+ await actions.delete(
+ { commit },
+ { conversationId: '7', scheduledMessageId: 3 }
+ );
+
+ expect(commit).toHaveBeenCalledWith(
+ types.default.DELETE_SCHEDULED_MESSAGE,
+ { conversationId: 7, scheduledMessageId: 3 }
+ );
+ });
+ });
+
+ describe('#upsertFromEvent', () => {
+ it('updates if record exists, adds if new', () => {
+ const commit = vi.fn();
+ const state = { records: { 5: [{ id: 1 }] } };
+
+ actions.upsertFromEvent(
+ { commit, state },
+ { id: 1, conversation_id: '5' }
+ );
+
+ expect(commit).toHaveBeenCalledWith(
+ types.default.UPDATE_SCHEDULED_MESSAGE,
+ expect.objectContaining({ conversationId: 5 })
+ );
+
+ commit.mockClear();
+
+ actions.upsertFromEvent(
+ { commit, state },
+ { id: 2, conversation_id: '5' }
+ );
+
+ expect(commit).toHaveBeenCalledWith(
+ types.default.ADD_SCHEDULED_MESSAGE,
+ expect.objectContaining({ conversationId: 5 })
+ );
+ });
+ });
+
+ describe('#removeFromEvent', () => {
+ it('commits DELETE_SCHEDULED_MESSAGE from event payload', () => {
+ const commit = vi.fn();
+
+ actions.removeFromEvent({ commit }, { id: 3, conversation_id: '8' });
+
+ expect(commit).toHaveBeenCalledWith(
+ types.default.DELETE_SCHEDULED_MESSAGE,
+ { conversationId: 8, scheduledMessageId: 3 }
+ );
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/specs/scheduledMessages/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/scheduledMessages/mutations.spec.js
new file mode 100644
index 000000000..ec16175ed
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/specs/scheduledMessages/mutations.spec.js
@@ -0,0 +1,43 @@
+import * as types from '../../../mutation-types';
+import { mutations } from '../../scheduledMessages';
+
+describe('#scheduledMessages mutations', () => {
+ describe('SET_SCHEDULED_MESSAGES', () => {
+ it('sets records for a conversation', () => {
+ const state = { records: {} };
+
+ mutations[types.default.SET_SCHEDULED_MESSAGES](state, {
+ conversationId: '4',
+ data: [{ id: 10 }],
+ });
+
+ expect(state.records).toEqual({ 4: [{ id: 10 }] });
+ });
+ });
+
+ describe('ADD_SCHEDULED_MESSAGE', () => {
+ it('adds new record or updates existing one', () => {
+ const state = { records: { 2: [{ id: 1, status: 'draft' }] } };
+
+ mutations[types.default.ADD_SCHEDULED_MESSAGE](state, {
+ conversationId: 2,
+ data: { id: 1, status: 'pending' },
+ });
+
+ expect(state.records[2]).toEqual([{ id: 1, status: 'pending' }]);
+ });
+ });
+
+ describe('DELETE_SCHEDULED_MESSAGE', () => {
+ it('removes record by id', () => {
+ const state = { records: { 3: [{ id: 1 }, { id: 2 }] } };
+
+ mutations[types.default.DELETE_SCHEDULED_MESSAGE](state, {
+ conversationId: 3,
+ scheduledMessageId: 1,
+ });
+
+ expect(state.records[3]).toEqual([{ id: 2 }]);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index b76867360..9b6d2f13f 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -29,6 +29,11 @@ export default {
SET_DRAFT_MESSAGES: 'SET_DRAFT_MESSAGES',
REMOVE_DRAFT_MESSAGES: 'REMOVE_DRAFT_MESSAGES',
SET_REPLY_EDITOR_MODE: 'SET_REPLY_EDITOR_MODE',
+ SET_SCHEDULED_MESSAGES_UI_FLAG: 'SET_SCHEDULED_MESSAGES_UI_FLAG',
+ SET_SCHEDULED_MESSAGES: 'SET_SCHEDULED_MESSAGES',
+ ADD_SCHEDULED_MESSAGE: 'ADD_SCHEDULED_MESSAGE',
+ UPDATE_SCHEDULED_MESSAGE: 'UPDATE_SCHEDULED_MESSAGE',
+ DELETE_SCHEDULED_MESSAGE: 'DELETE_SCHEDULED_MESSAGE',
SET_CURRENT_CHAT_WINDOW: 'SET_CURRENT_CHAT_WINDOW',
CLEAR_CURRENT_CHAT_WINDOW: 'CLEAR_CURRENT_CHAT_WINDOW',
diff --git a/app/jobs/scheduled_messages/send_scheduled_message_job.rb b/app/jobs/scheduled_messages/send_scheduled_message_job.rb
new file mode 100644
index 000000000..e1874779c
--- /dev/null
+++ b/app/jobs/scheduled_messages/send_scheduled_message_job.rb
@@ -0,0 +1,69 @@
+class ScheduledMessages::SendScheduledMessageJob < ApplicationJob
+ include Events::Types
+
+ queue_as :medium
+
+ def perform(scheduled_message_id)
+ scheduled_message = ScheduledMessage.find_by(id: scheduled_message_id)
+ return unless scheduled_message
+
+ Current.executed_by = scheduled_message.author if scheduled_message.author.is_a?(AutomationRule)
+ scheduled_message.with_lock { send_if_ready(scheduled_message) }
+ rescue StandardError => e
+ Rails.logger.error("Scheduled message #{scheduled_message_id} failed: #{e.class} #{e.message}")
+ if scheduled_message&.pending?
+ scheduled_message.update!(status: :failed)
+ dispatch_event(scheduled_message)
+ end
+ ensure
+ Current.reset
+ end
+
+ private
+
+ def send_if_ready(scheduled_message)
+ return unless scheduled_message.pending?
+ return unless scheduled_message.due_for_sending?
+
+ message = send_message(scheduled_message)
+ update_scheduled_message_status(scheduled_message, message)
+ end
+
+ def send_message(scheduled_message)
+ params = {
+ content: scheduled_message.content,
+ private: false,
+ message_type: 'outgoing',
+ scheduled_message: scheduled_message
+ }
+ params[:template_params] = scheduled_message.template_params if scheduled_message.template_params.present?
+ params[:attachments] = [scheduled_message.attachment.blob.signed_id] if scheduled_message.attachment.attached?
+ params.merge!(scheduled_message_content_attributes(scheduled_message))
+
+ Messages::MessageBuilder.new(message_author(scheduled_message), scheduled_message.conversation, params).perform
+ end
+
+ def message_author(scheduled_message)
+ scheduled_message.author.is_a?(User) ? scheduled_message.author : nil
+ end
+
+ def scheduled_message_content_attributes(scheduled_message)
+ return {} unless scheduled_message.author.is_a?(AutomationRule)
+
+ { content_attributes: { automation_rule_id: scheduled_message.author_id } }
+ end
+
+ def update_scheduled_message_status(scheduled_message, message)
+ return unless scheduled_message.pending?
+
+ new_status = message.failed? ? :failed : :sent
+ return if scheduled_message.status == new_status.to_s
+
+ scheduled_message.update!(status: new_status, message: message)
+ dispatch_event(scheduled_message)
+ end
+
+ def dispatch_event(scheduled_message)
+ Rails.configuration.dispatcher.dispatch(SCHEDULED_MESSAGE_UPDATED, Time.zone.now, scheduled_message: scheduled_message)
+ end
+end
diff --git a/app/jobs/scheduled_messages/trigger_scheduled_messages_job.rb b/app/jobs/scheduled_messages/trigger_scheduled_messages_job.rb
new file mode 100644
index 000000000..bd81ec45d
--- /dev/null
+++ b/app/jobs/scheduled_messages/trigger_scheduled_messages_job.rb
@@ -0,0 +1,9 @@
+class ScheduledMessages::TriggerScheduledMessagesJob < ApplicationJob
+ queue_as :scheduled_jobs
+
+ def perform
+ ScheduledMessage.due_for_sending.find_each(batch_size: 100) do |scheduled_message|
+ ScheduledMessages::SendScheduledMessageJob.perform_later(scheduled_message.id)
+ end
+ end
+end
diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb
index 61aa4f535..9c888c3de 100644
--- a/app/listeners/action_cable_listener.rb
+++ b/app/listeners/action_cable_listener.rb
@@ -1,4 +1,4 @@
-class ActionCableListener < BaseListener
+class ActionCableListener < BaseListener # rubocop:disable Metrics/ClassLength
include Events::Types
def notification_created(event)
@@ -54,6 +54,30 @@ class ActionCableListener < BaseListener
broadcast(account, tokens, MESSAGE_UPDATED, message.push_event_data.merge(previous_changes: event.data[:previous_changes]))
end
+ def scheduled_message_created(event)
+ scheduled_message = event.data[:scheduled_message]
+ account = scheduled_message.account
+ tokens = user_tokens(account, scheduled_message.conversation.inbox.members)
+
+ broadcast(account, tokens, SCHEDULED_MESSAGE_CREATED, scheduled_message.push_event_data)
+ end
+
+ def scheduled_message_updated(event)
+ scheduled_message = event.data[:scheduled_message]
+ account = scheduled_message.account
+ tokens = user_tokens(account, scheduled_message.conversation.inbox.members)
+
+ broadcast(account, tokens, SCHEDULED_MESSAGE_UPDATED, scheduled_message.push_event_data)
+ end
+
+ def scheduled_message_deleted(event)
+ scheduled_message = event.data[:scheduled_message]
+ account = scheduled_message.account
+ tokens = user_tokens(account, scheduled_message.conversation.inbox.members)
+
+ broadcast(account, tokens, SCHEDULED_MESSAGE_DELETED, scheduled_message.push_event_data)
+ end
+
def first_reply_created(event)
message, account = extract_message_and_account(event)
conversation = message.conversation
diff --git a/app/models/account.rb b/app/models/account.rb
index dad5bc18d..fe9df442c 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -120,6 +120,7 @@ class Account < ApplicationRecord
has_many :notification_settings, dependent: :destroy_async
has_many :notifications, dependent: :destroy_async
has_many :portals, dependent: :destroy_async, class_name: '::Portal'
+ has_many :scheduled_messages, dependent: :destroy_async
has_many :sms_channels, dependent: :destroy_async, class_name: '::Channel::Sms'
has_many :teams, dependent: :destroy_async
has_many :telegram_channels, dependent: :destroy_async, class_name: '::Channel::Telegram'
diff --git a/app/models/automation_rule.rb b/app/models/automation_rule.rb
index 9dc4d97eb..ba20bc0e5 100644
--- a/app/models/automation_rule.rb
+++ b/app/models/automation_rule.rb
@@ -22,12 +22,14 @@ class AutomationRule < ApplicationRecord
include Reauthorizable
belongs_to :account
+ has_many :scheduled_messages, as: :author, dependent: :destroy
has_many_attached :files
validate :json_conditions_format
validate :json_actions_format
validate :query_operator_presence
validate :query_operator_value
+ validate :scheduled_message_params
validates :account_id, presence: true
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
@@ -42,7 +44,7 @@ class AutomationRule < ApplicationRecord
def actions_attributes
%w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation
send_attachment change_status resolve_conversation open_conversation snooze_conversation change_priority send_email_transcript
- add_private_note].freeze
+ add_private_note create_scheduled_message].freeze
end
def file_base_data
@@ -103,6 +105,29 @@ class AutomationRule < ApplicationRecord
operator = query_operator.upcase
errors.add(:conditions, 'Query operator must be either "AND" or "OR"') unless %w[AND OR].include?(operator)
end
+
+ def scheduled_message_params
+ return if actions.blank?
+
+ actions.each do |action|
+ next unless action['action_name'] == 'create_scheduled_message'
+
+ validate_scheduled_message_action(action)
+ end
+ end
+
+ def validate_scheduled_message_action(action)
+ params = action['action_params']&.first || {}
+ delay_minutes = params['delay_minutes'].to_i
+
+ errors.add(:actions, I18n.t('errors.automation.scheduled_message.delay_out_of_range')) unless delay_minutes.between?(0, 999_999)
+
+ has_content = params['content'].present?
+ has_attachment = params['blob_id'].present?
+ return if has_content || has_attachment
+
+ errors.add(:actions, I18n.t('errors.automation.scheduled_message.content_or_attachment_required'))
+ end
end
AutomationRule.include_mod_with('Audit::AutomationRule')
diff --git a/app/models/concerns/scheduled_message_handler.rb b/app/models/concerns/scheduled_message_handler.rb
new file mode 100644
index 000000000..c9294e277
--- /dev/null
+++ b/app/models/concerns/scheduled_message_handler.rb
@@ -0,0 +1,46 @@
+module ScheduledMessageHandler
+ extend ActiveSupport::Concern
+
+ included do
+ after_update_commit :update_scheduled_message_status, if: :should_update_scheduled_message?
+ end
+
+ private
+
+ def should_update_scheduled_message?
+ saved_change_to_status? && scheduled_message_id.present?
+ end
+
+ def scheduled_message_id
+ additional_attributes&.dig('scheduled_message_id')
+ end
+
+ def update_scheduled_message_status
+ scheduled_message = conversation.scheduled_messages.find_by(id: scheduled_message_id)
+ return unless scheduled_message
+
+ new_status = determine_scheduled_message_status
+ return unless new_status
+ return if scheduled_message.status == new_status.to_s
+
+ scheduled_message.update!(status: new_status)
+ dispatch_scheduled_message_update(scheduled_message)
+ end
+
+ def determine_scheduled_message_status
+ case status
+ when 'delivered', 'read'
+ :sent
+ when 'failed'
+ :failed
+ end
+ end
+
+ def dispatch_scheduled_message_update(scheduled_message)
+ Rails.configuration.dispatcher.dispatch(
+ Events::Types::SCHEDULED_MESSAGE_UPDATED,
+ Time.zone.now,
+ scheduled_message: scheduled_message
+ )
+ end
+end
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index 286b57a03..4014f0c5e 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -113,6 +113,7 @@ class Conversation < ApplicationRecord
has_many :notifications, as: :primary_actor, dependent: :destroy_async
has_many :attachments, through: :messages
has_many :reporting_events, dependent: :destroy_async
+ has_many :scheduled_messages, dependent: :destroy_async
before_save :ensure_snooze_until_reset
before_create :determine_conversation_status
diff --git a/app/models/inbox.rb b/app/models/inbox.rb
index 9cdff17f9..ce45a8987 100644
--- a/app/models/inbox.rb
+++ b/app/models/inbox.rb
@@ -67,6 +67,7 @@ class Inbox < ApplicationRecord
has_many :members, through: :inbox_members, source: :user
has_many :conversations, dependent: :destroy_async
has_many :messages, dependent: :destroy_async
+ has_many :scheduled_messages, dependent: :destroy_async
has_one :inbox_assignment_policy, dependent: :destroy
has_one :assignment_policy, through: :inbox_assignment_policy
diff --git a/app/models/message.rb b/app/models/message.rb
index c0b99c064..2e4b2fd68 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -43,6 +43,7 @@ class Message < ApplicationRecord
include MessageFilterHelpers
include Liquidable
+ include ScheduledMessageHandler
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
TEMPLATE_PARAMS_SCHEMA = {
diff --git a/app/models/scheduled_message.rb b/app/models/scheduled_message.rb
new file mode 100644
index 000000000..c49205e7e
--- /dev/null
+++ b/app/models/scheduled_message.rb
@@ -0,0 +1,154 @@
+# == Schema Information
+#
+# Table name: scheduled_messages
+#
+# id :bigint not null, primary key
+# author_type :string not null
+# content :text
+# scheduled_at :datetime
+# status :integer default("draft"), not null
+# template_params :jsonb
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint not null
+# author_id :bigint not null
+# conversation_id :bigint not null
+# inbox_id :bigint not null
+# message_id :bigint
+#
+# Indexes
+#
+# idx_on_author_type_author_id_status_6997d67ef6 (author_type,author_id,status)
+# index_scheduled_messages_on_account_id (account_id)
+# index_scheduled_messages_on_account_id_and_status (account_id,status)
+# index_scheduled_messages_on_author (author_type,author_id)
+# index_scheduled_messages_on_conversation_id (conversation_id)
+# index_scheduled_messages_on_conversation_id_and_scheduled_at (conversation_id,scheduled_at)
+# index_scheduled_messages_on_conversation_id_and_status (conversation_id,status)
+# index_scheduled_messages_on_inbox_id (inbox_id)
+# index_scheduled_messages_on_inbox_id_and_status (inbox_id,status)
+# index_scheduled_messages_on_message_id (message_id)
+# index_scheduled_messages_on_status_and_scheduled_at (status,scheduled_at)
+#
+# Foreign Keys
+#
+# fk_rails_... (account_id => accounts.id)
+# fk_rails_... (conversation_id => conversations.id)
+# fk_rails_... (inbox_id => inboxes.id)
+# fk_rails_... (message_id => messages.id)
+#
+class ScheduledMessage < ApplicationRecord
+ include Rails.application.routes.url_helpers
+
+ belongs_to :account
+ belongs_to :inbox
+ belongs_to :conversation
+ belongs_to :author, polymorphic: true
+ belongs_to :message, optional: true
+
+ has_one_attached :attachment
+
+ enum status: { draft: 0, pending: 1, sent: 2, failed: 3 }
+
+ before_destroy :prevent_destroy_if_processed
+
+ validates :scheduled_at, presence: true, unless: -> { status == 'draft' }
+ validates :content, presence: true, unless: :content_optional?
+ validates :content, length: { maximum: 150_000 }
+ validate :status_must_be_draft_or_pending, on: :create
+ validate :must_be_editable, on: :update
+ validate :scheduled_at_must_be_in_future, if: :should_validate_future_schedule?
+
+ scope :due_for_sending, -> { pending.where('scheduled_at <= ?', Time.current) }
+
+ def due_for_sending?
+ scheduled_at.present? && scheduled_at <= Time.current
+ end
+
+ def push_event_data
+ data = {
+ id: id,
+ content: content,
+ inbox_id: inbox_id,
+ conversation_id: conversation.display_id,
+ account_id: account_id,
+ status: status,
+ scheduled_at: scheduled_at&.to_i,
+ template_params: template_params,
+ author_id: author_id,
+ author_type: author_type,
+ created_at: created_at.to_i,
+ updated_at: updated_at.to_i
+ }
+
+ data[:author] = author_event_data if author.present?
+ data[:attachment] = attachment_data if attachment.attached?
+ data
+ end
+
+ def attachment_data
+ return unless attachment.attached?
+
+ {
+ id: attachment.id,
+ scheduled_message_id: id,
+ file_type: attachment.content_type,
+ account_id: account_id,
+ file_url: url_for(attachment),
+ blob_id: attachment.blob.signed_id,
+ filename: attachment.filename.to_s
+ }
+ end
+
+ private
+
+ def status_must_be_draft_or_pending
+ return if draft? || pending?
+
+ errors.add(:status, 'must be draft or pending when creating a scheduled message')
+ end
+
+ def must_be_editable
+ return if status_was.in?(%w[sent failed]) && only_status_changed? && status.in?(%w[sent failed])
+
+ return if status_was.in?(%w[draft pending])
+
+ errors.add(:base, 'Scheduled message can only be modified while draft or pending')
+ end
+
+ def only_status_changed?
+ changed_attributes.keys == ['status']
+ end
+
+ def prevent_destroy_if_processed
+ return unless sent? || failed?
+
+ errors.add(:base, 'Cannot delete a scheduled message that has already been sent or failed')
+ throw(:abort)
+ end
+
+ def scheduled_at_must_be_in_future
+ return if scheduled_at.blank?
+ return if scheduled_at > Time.current
+
+ errors.add(:scheduled_at, 'must be in the future')
+ end
+
+ def should_validate_future_schedule?
+ return false unless pending?
+
+ new_record? || scheduled_at_changed? || status_changed?
+ end
+
+ def content_optional?
+ template_params.present? || attachment.attached?
+ end
+
+ def author_event_data
+ return author.push_event_data if author.is_a?(User)
+
+ data = { id: author_id, type: author_type }
+ data[:name] = author.name if author.respond_to?(:name)
+ data
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 7ed5f435b..199f5f589 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -100,6 +100,8 @@ class User < ApplicationRecord
has_many :messages, as: :sender, dependent: :nullify
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
+ has_many :scheduled_messages, as: :author, dependent: :destroy
+
has_many :custom_filters, dependent: :destroy_async
has_many :dashboard_apps, dependent: :nullify
has_many :mentions, dependent: :destroy_async
diff --git a/app/policies/scheduled_message_policy.rb b/app/policies/scheduled_message_policy.rb
new file mode 100644
index 000000000..7631a19da
--- /dev/null
+++ b/app/policies/scheduled_message_policy.rb
@@ -0,0 +1,51 @@
+class ScheduledMessagePolicy < ApplicationPolicy
+ def index?
+ accessible?
+ end
+
+ def create?
+ accessible?
+ end
+
+ def update?
+ accessible?
+ end
+
+ def destroy?
+ accessible?
+ end
+
+ private
+
+ def accessible?
+ administrator? || agent_bot? || agent_can_view_conversation?
+ end
+
+ def agent_can_view_conversation?
+ inbox_access? || team_access?
+ end
+
+ def administrator?
+ account_user&.administrator?
+ end
+
+ def agent_bot?
+ user.is_a?(AgentBot)
+ end
+
+ def conversation
+ record.respond_to?(:conversation) ? record.conversation : record
+ end
+
+ def inbox_access?
+ user.inboxes.where(account_id: account&.id).exists?(id: conversation.inbox_id)
+ end
+
+ def team_access?
+ return false if conversation.team_id.blank?
+
+ user.teams.where(account_id: account&.id).exists?(id: conversation.team_id)
+ end
+end
+
+ScheduledMessagePolicy.prepend_mod_with('ScheduledMessagePolicy')
diff --git a/app/services/action_service.rb b/app/services/action_service.rb
index fdc3a90ab..525ea3b88 100644
--- a/app/services/action_service.rb
+++ b/app/services/action_service.rb
@@ -79,6 +79,30 @@ class ActionService
end
end
+ def create_scheduled_message(action_params)
+ return if conversation_a_tweet?
+
+ params = action_params.first&.with_indifferent_access || {}
+ delay_minutes = params[:delay_minutes].to_i.clamp(0, 999_999)
+ scheduled_at = delay_minutes.minutes.from_now
+
+ scheduled_message = @conversation.scheduled_messages.new(
+ account: @account,
+ inbox: @conversation.inbox,
+ author: scheduled_message_author,
+ content: params[:content],
+ scheduled_at: scheduled_at,
+ status: :pending,
+ template_params: {}
+ )
+
+ blob = scheduled_message_attachment_blob(params[:blob_id])
+ scheduled_message.attachment.attach(blob) if blob.present?
+
+ scheduled_message.save!
+ dispatch_scheduled_message_created(scheduled_message)
+ end
+
private
def agent_belongs_to_inbox?(agent_ids)
@@ -97,6 +121,24 @@ class ActionService
@conversation.additional_attributes['type'] == 'tweet'
end
+
+ def scheduled_message_author
+ Current.executed_by || Current.user
+ end
+
+ def scheduled_message_attachment_blob(blob_id)
+ return if blob_id.blank?
+
+ ActiveStorage::Blob.find_by(id: blob_id)
+ end
+
+ def dispatch_scheduled_message_created(scheduled_message)
+ Rails.configuration.dispatcher.dispatch(
+ Events::Types::SCHEDULED_MESSAGE_CREATED,
+ Time.zone.now,
+ scheduled_message: scheduled_message
+ )
+ end
end
ActionService.include_mod_with('ActionService')
diff --git a/app/services/automation_rules/action_service.rb b/app/services/automation_rules/action_service.rb
index e409faba2..14f7e41e6 100644
--- a/app/services/automation_rules/action_service.rb
+++ b/app/services/automation_rules/action_service.rb
@@ -61,4 +61,11 @@ class AutomationRules::ActionService < ActionService
TeamNotifications::AutomationNotificationMailer.conversation_creation(@conversation, team, params[0][:message])&.deliver_now
end
end
+
+ def scheduled_message_attachment_blob(blob_id)
+ return if blob_id.blank?
+
+ attachment = @rule.files.find { |file| file.blob_id == blob_id.to_i }
+ attachment&.blob
+ end
end
diff --git a/app/views/api/v1/accounts/conversations/scheduled_messages/create.json.jbuilder b/app/views/api/v1/accounts/conversations/scheduled_messages/create.json.jbuilder
new file mode 100644
index 000000000..a472e2552
--- /dev/null
+++ b/app/views/api/v1/accounts/conversations/scheduled_messages/create.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/scheduled_message', scheduled_message: @scheduled_message
diff --git a/app/views/api/v1/accounts/conversations/scheduled_messages/destroy.json.jbuilder b/app/views/api/v1/accounts/conversations/scheduled_messages/destroy.json.jbuilder
new file mode 100644
index 000000000..a472e2552
--- /dev/null
+++ b/app/views/api/v1/accounts/conversations/scheduled_messages/destroy.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/scheduled_message', scheduled_message: @scheduled_message
diff --git a/app/views/api/v1/accounts/conversations/scheduled_messages/index.json.jbuilder b/app/views/api/v1/accounts/conversations/scheduled_messages/index.json.jbuilder
new file mode 100644
index 000000000..0849e70f5
--- /dev/null
+++ b/app/views/api/v1/accounts/conversations/scheduled_messages/index.json.jbuilder
@@ -0,0 +1,5 @@
+json.payload do
+ json.array! @scheduled_messages do |scheduled_message|
+ json.partial! 'api/v1/models/scheduled_message', scheduled_message: scheduled_message
+ end
+end
diff --git a/app/views/api/v1/accounts/conversations/scheduled_messages/update.json.jbuilder b/app/views/api/v1/accounts/conversations/scheduled_messages/update.json.jbuilder
new file mode 100644
index 000000000..a472e2552
--- /dev/null
+++ b/app/views/api/v1/accounts/conversations/scheduled_messages/update.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/scheduled_message', scheduled_message: @scheduled_message
diff --git a/app/views/api/v1/models/_message.json.jbuilder b/app/views/api/v1/models/_message.json.jbuilder
index 583bc6fa6..7cf46081d 100644
--- a/app/views/api/v1/models/_message.json.jbuilder
+++ b/app/views/api/v1/models/_message.json.jbuilder
@@ -7,6 +7,7 @@ json.message_type message.message_type_before_type_cast
json.content_type message.content_type
json.status message.status
json.content_attributes message.content_attributes
+json.additional_attributes message.additional_attributes
json.created_at message.created_at.to_i
json.private message.private
json.source_id message.source_id
diff --git a/app/views/api/v1/models/_scheduled_message.json.jbuilder b/app/views/api/v1/models/_scheduled_message.json.jbuilder
new file mode 100644
index 000000000..49480bfd4
--- /dev/null
+++ b/app/views/api/v1/models/_scheduled_message.json.jbuilder
@@ -0,0 +1,26 @@
+json.id scheduled_message.id
+json.content scheduled_message.content
+json.inbox_id scheduled_message.inbox_id
+json.conversation_id scheduled_message.conversation.display_id
+json.account_id scheduled_message.account_id
+json.status scheduled_message.status
+json.scheduled_at scheduled_message.scheduled_at&.to_i
+json.template_params scheduled_message.template_params
+json.author_id scheduled_message.author_id
+json.author_type scheduled_message.author_type
+json.created_at scheduled_message.created_at.to_i
+json.updated_at scheduled_message.updated_at.to_i
+
+if scheduled_message.author.is_a?(User)
+ json.author do
+ json.partial! 'api/v1/models/agent', formats: [:json], resource: scheduled_message.author
+ end
+elsif scheduled_message.author.present?
+ json.author do
+ json.id scheduled_message.author_id
+ json.type scheduled_message.author_type
+ json.name scheduled_message.author.respond_to?(:name) ? scheduled_message.author.name : nil
+ end
+end
+
+json.attachment scheduled_message.attachment_data if scheduled_message.attachment.attached?
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index dd5c71a4d..7a0a1720b 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -33,6 +33,9 @@ Sidekiq.configure_server do |config|
end
# https://github.com/ondrejbartas/sidekiq-cron
+# Reduce poll interval for second-precision cron jobs
+Sidekiq::Options[:cron_poll_interval] = 10
+
Rails.application.reloader.to_prepare do
Sidekiq::Cron::Job.load_from_hash YAML.load_file(schedule_file) if File.exist?(schedule_file) && Sidekiq.server?
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 550be1aed..3dcf1cff4 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -64,6 +64,10 @@ en:
not_found: Assignment policy not found
attachments:
invalid: Invalid attachment
+ automation:
+ scheduled_message:
+ delay_out_of_range: Delay minutes must be between 0 and 999999
+ content_or_attachment_required: Scheduled message must have content or attachment
saml:
feature_not_enabled: SAML feature not enabled for this account
sso_not_enabled: SAML SSO is not enabled for this installation
diff --git a/config/locales/pt_BR.yml b/config/locales/pt_BR.yml
index 03af60c0e..c33655a97 100644
--- a/config/locales/pt_BR.yml
+++ b/config/locales/pt_BR.yml
@@ -50,6 +50,10 @@ pt_BR:
not_found: Política de atribuição não encontrada
attachments:
invalid: Anexo inválido
+ automation:
+ scheduled_message:
+ delay_out_of_range: O tempo de atraso deve ser entre 0 e 999999 minutos
+ content_or_attachment_required: A mensagem agendada deve ter conteúdo ou anexo
saml:
feature_not_enabled: SAML não está habilitado para esta conta
sso_not_enabled: O SSO via SAML não está habilitado para esta instalação
diff --git a/config/routes.rb b/config/routes.rb
index d5f990379..4858f98bf 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -129,6 +129,7 @@ Rails.application.routes.draw do
end
resources :attachments, only: [:update]
end
+ resources :scheduled_messages, only: [:index, :create, :update, :destroy]
resources :assignments, only: [:create]
resources :labels, only: [:create, :index]
resource :participants, only: [:show, :create, :update, :destroy]
diff --git a/config/schedule.yml b/config/schedule.yml
index c039719fa..43d3d9d89 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -13,6 +13,12 @@ trigger_scheduled_items_job:
class: 'TriggerScheduledItemsJob'
queue: scheduled_jobs
+# executed every minute.
+trigger_scheduled_messages_job:
+ cron: '* * * * *'
+ class: 'ScheduledMessages::TriggerScheduledMessagesJob'
+ queue: scheduled_jobs
+
# executed At every minute..
trigger_imap_email_inboxes_job:
cron: '*/1 * * * *'
diff --git a/db/migrate/20260121190545_create_scheduled_messages.rb b/db/migrate/20260121190545_create_scheduled_messages.rb
new file mode 100644
index 000000000..763ecd811
--- /dev/null
+++ b/db/migrate/20260121190545_create_scheduled_messages.rb
@@ -0,0 +1,32 @@
+class CreateScheduledMessages < ActiveRecord::Migration[7.1]
+ def change
+ create_table :scheduled_messages do |t|
+ t.text :content
+ t.jsonb :template_params, default: {}
+ t.datetime :scheduled_at
+ t.integer :status, default: 0, null: false
+
+ t.references :account, null: false, foreign_key: true
+ t.references :conversation, null: false, foreign_key: true
+ t.references :inbox, null: false, foreign_key: true
+
+ t.references :author, null: false, polymorphic: true
+ t.references :message, null: true, foreign_key: true
+
+ t.timestamps
+ end
+
+ add_scheduled_messages_indexes
+ end
+
+ private
+
+ def add_scheduled_messages_indexes
+ add_index :scheduled_messages, [:account_id, :status]
+ add_index :scheduled_messages, [:conversation_id, :status]
+ add_index :scheduled_messages, [:conversation_id, :scheduled_at]
+ add_index :scheduled_messages, [:status, :scheduled_at]
+ add_index :scheduled_messages, [:author_type, :author_id, :status]
+ add_index :scheduled_messages, [:inbox_id, :status]
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 6427b5d57..e1c6ef1ed 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2026_01_14_201315) do
+ActiveRecord::Schema[7.1].define(version: 2026_01_22_175206) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -1126,6 +1126,32 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_14_201315) do
t.index ["user_id"], name: "index_reporting_events_on_user_id"
end
+ create_table "scheduled_messages", force: :cascade do |t|
+ t.text "content"
+ t.jsonb "template_params", default: {}
+ t.datetime "scheduled_at"
+ t.integer "status", default: 0, null: false
+ t.bigint "account_id", null: false
+ t.bigint "conversation_id", null: false
+ t.bigint "inbox_id", null: false
+ t.string "author_type", null: false
+ t.bigint "author_id", null: false
+ t.bigint "message_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "status"], name: "index_scheduled_messages_on_account_id_and_status"
+ t.index ["account_id"], name: "index_scheduled_messages_on_account_id"
+ t.index ["author_type", "author_id", "status"], name: "idx_on_author_type_author_id_status_6997d67ef6"
+ t.index ["author_type", "author_id"], name: "index_scheduled_messages_on_author"
+ t.index ["conversation_id", "scheduled_at"], name: "index_scheduled_messages_on_conversation_id_and_scheduled_at"
+ t.index ["conversation_id", "status"], name: "index_scheduled_messages_on_conversation_id_and_status"
+ t.index ["conversation_id"], name: "index_scheduled_messages_on_conversation_id"
+ t.index ["inbox_id", "status"], name: "index_scheduled_messages_on_inbox_id_and_status"
+ t.index ["inbox_id"], name: "index_scheduled_messages_on_inbox_id"
+ t.index ["message_id"], name: "index_scheduled_messages_on_message_id"
+ t.index ["status", "scheduled_at"], name: "index_scheduled_messages_on_status_and_scheduled_at"
+ end
+
create_table "sla_events", force: :cascade do |t|
t.bigint "applied_sla_id", null: false
t.bigint "conversation_id", null: false
@@ -1274,6 +1300,10 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_14_201315) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "inboxes", "portals"
+ add_foreign_key "scheduled_messages", "accounts"
+ add_foreign_key "scheduled_messages", "conversations"
+ add_foreign_key "scheduled_messages", "inboxes"
+ add_foreign_key "scheduled_messages", "messages"
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
on("accounts").
after(:insert).
diff --git a/lib/events/types.rb b/lib/events/types.rb
index f686da5f8..cb76babbe 100644
--- a/lib/events/types.rb
+++ b/lib/events/types.rb
@@ -41,6 +41,11 @@ module Events::Types
MESSAGE_UPDATED = 'message.updated'
MESSAGES_READ = 'messages.read'
+ # scheduled message events
+ SCHEDULED_MESSAGE_CREATED = 'scheduled_message.created'
+ SCHEDULED_MESSAGE_UPDATED = 'scheduled_message.updated'
+ SCHEDULED_MESSAGE_DELETED = 'scheduled_message.deleted'
+
# contact events
CONTACT_CREATED = 'contact.created'
CONTACT_UPDATED = 'contact.updated'
diff --git a/spec/builders/messages/message_builder_spec.rb b/spec/builders/messages/message_builder_spec.rb
index 1cb1b07da..a2922a0c5 100644
--- a/spec/builders/messages/message_builder_spec.rb
+++ b/spec/builders/messages/message_builder_spec.rb
@@ -340,4 +340,45 @@ describe Messages::MessageBuilder do
end
end
end
+
+ describe 'scheduled_message metadata' do
+ let(:scheduled_message) { create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: user, content: 'Hello') }
+ let(:params) do
+ ActionController::Parameters.new({
+ content: 'test',
+ scheduled_message: scheduled_message
+ })
+ end
+
+ it 'includes scheduled_message_id in additional_attributes' do
+ message = message_builder
+
+ expect(message.additional_attributes['scheduled_message_id']).to eq(scheduled_message.id)
+ end
+
+ it 'includes scheduled_by with author info' do
+ message = message_builder
+
+ expect(message.additional_attributes['scheduled_by']).to include('id' => user.id, 'type' => 'User', 'name' => user.name)
+ end
+
+ it 'includes scheduled_at timestamp' do
+ message = message_builder
+
+ expect(message.additional_attributes['scheduled_at']).to eq(scheduled_message.updated_at.to_i)
+ end
+
+ context 'when author is AutomationRule' do
+ let(:automation_rule) { create(:automation_rule, account: account) }
+ let(:scheduled_message) do
+ create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: automation_rule, content: 'Hello')
+ end
+
+ it 'includes scheduled_by with automation_rule info' do
+ message = message_builder
+
+ expect(message.additional_attributes['scheduled_by']).to include('id' => automation_rule.id, 'type' => 'AutomationRule')
+ end
+ end
+ end
end
diff --git a/spec/controllers/api/v1/accounts/conversations/scheduled_messages_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/scheduled_messages_controller_spec.rb
new file mode 100644
index 000000000..0520a3ade
--- /dev/null
+++ b/spec/controllers/api/v1/accounts/conversations/scheduled_messages_controller_spec.rb
@@ -0,0 +1,126 @@
+require 'rails_helper'
+
+RSpec.describe 'Scheduled Messages API', type: :request do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:scheduled_message) { create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: agent) }
+
+ before do
+ create(:inbox_member, inbox: inbox, user: agent)
+ end
+
+ def scheduled_messages_url
+ api_v1_account_conversation_scheduled_messages_url(account_id: account.id, conversation_id: conversation.display_id)
+ end
+
+ def scheduled_message_url(scheduled_message)
+ api_v1_account_conversation_scheduled_message_url(
+ account_id: account.id,
+ conversation_id: conversation.display_id,
+ id: scheduled_message.id
+ )
+ end
+
+ it 'returns unauthorized for unauthenticated users' do
+ get scheduled_messages_url
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ describe 'GET #index' do
+ it 'returns all scheduled messages ordered by scheduled_at' do
+ later = create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: agent, scheduled_at: 5.minutes.from_now)
+ earlier = create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: agent,
+ scheduled_at: 2.minutes.from_now)
+
+ get scheduled_messages_url, headers: agent.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['payload'].pluck('id')).to eq([earlier.id, later.id])
+ end
+ end
+
+ describe 'POST #create' do
+ it 'creates a pending scheduled message with future time' do
+ freeze_time do
+ scheduled_at = 2.minutes.from_now
+
+ post scheduled_messages_url,
+ params: { content: 'Hello', status: 'pending', scheduled_at: scheduled_at.iso8601 },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(conversation.scheduled_messages.last).to have_attributes(status: 'pending', author_id: agent.id)
+ end
+ end
+
+ it 'creates a draft without scheduled_at' do
+ post scheduled_messages_url,
+ params: { content: 'Draft message', status: 'draft' },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(conversation.scheduled_messages.last).to have_attributes(status: 'draft', scheduled_at: nil)
+ end
+
+ it 'rejects pending schedules not in the future' do
+ freeze_time do
+ post scheduled_messages_url,
+ params: { content: 'Hello', status: 'pending', scheduled_at: Time.current.iso8601 },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+ end
+ end
+
+ describe 'PATCH #update' do
+ it 'updates a draft to pending with a future schedule' do
+ freeze_time do
+ draft = create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: agent, status: :draft,
+ scheduled_at: nil)
+
+ patch scheduled_message_url(draft),
+ params: { status: 'pending', scheduled_at: 2.minutes.from_now.iso8601 },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(draft.reload.status).to eq('pending')
+ end
+ end
+
+ it 'rejects updates for sent messages' do
+ scheduled_message.update!(status: :sent)
+
+ patch scheduled_message_url(scheduled_message),
+ params: { content: 'Updated' },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+ end
+
+ describe 'DELETE #destroy' do
+ it 'deletes pending scheduled messages' do
+ delete scheduled_message_url(scheduled_message), headers: agent.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(ScheduledMessage.exists?(scheduled_message.id)).to be(false)
+ end
+
+ it 'rejects delete for sent messages' do
+ scheduled_message.update!(status: :sent)
+
+ delete scheduled_message_url(scheduled_message), headers: agent.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(ScheduledMessage.exists?(scheduled_message.id)).to be(true)
+ end
+ end
+end
diff --git a/spec/factories/scheduled_messages.rb b/spec/factories/scheduled_messages.rb
new file mode 100644
index 000000000..6118b1a1d
--- /dev/null
+++ b/spec/factories/scheduled_messages.rb
@@ -0,0 +1,11 @@
+FactoryBot.define do
+ factory :scheduled_message do
+ account
+ inbox
+ conversation
+ association :author, factory: :user
+ content { 'Scheduled message content' }
+ scheduled_at { 2.minutes.from_now }
+ status { :pending }
+ end
+end
diff --git a/spec/jobs/scheduled_messages/send_scheduled_message_job_spec.rb b/spec/jobs/scheduled_messages/send_scheduled_message_job_spec.rb
new file mode 100644
index 000000000..db57880fc
--- /dev/null
+++ b/spec/jobs/scheduled_messages/send_scheduled_message_job_spec.rb
@@ -0,0 +1,88 @@
+require 'rails_helper'
+
+RSpec.describe ScheduledMessages::SendScheduledMessageJob, type: :job do
+ let(:account) { create(:account) }
+ let(:author) { create(:user, account: account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let!(:scheduled_message) { create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: author) }
+
+ describe '#perform' do
+ it 'creates message with metadata and marks as sent' do
+ travel_to(3.minutes.from_now) do
+ described_class.new.perform(scheduled_message.id)
+
+ message = conversation.messages.last
+ expect(message.content).to eq(scheduled_message.content)
+ expect(message.additional_attributes['scheduled_message_id']).to eq(scheduled_message.id)
+ expect(message.additional_attributes['scheduled_by']).to include('id' => author.id, 'type' => 'User')
+ expect(scheduled_message.reload.status).to eq('sent')
+ end
+ end
+
+ it 'sets automation_rule_id when author is AutomationRule' do
+ automation_rule = create(:automation_rule, account: account)
+ scheduled_message = create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: automation_rule)
+
+ travel_to(3.minutes.from_now) do
+ described_class.new.perform(scheduled_message.id)
+
+ message = conversation.messages.last
+ expect(message.content_attributes['automation_rule_id']).to eq(automation_rule.id)
+ expect(message.additional_attributes['scheduled_by']).to include('id' => automation_rule.id, 'type' => 'AutomationRule')
+ end
+ end
+
+ it 'includes template_params when present' do
+ template_params = { 'name' => 'sample_template', 'language' => 'en' }
+ scheduled_message = create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: author, content: nil,
+ template_params: template_params)
+
+ travel_to(3.minutes.from_now) do
+ described_class.new.perform(scheduled_message.id)
+
+ expect(conversation.messages.last.additional_attributes['template_params']).to eq(template_params)
+ end
+ end
+
+ it 'includes attachment when present' do
+ file = Rack::Test::UploadedFile.new(Rails.root.join('spec/assets/avatar.png'), 'image/png')
+ scheduled_message = create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: author, content: nil,
+ attachment: file)
+
+ travel_to(3.minutes.from_now) do
+ described_class.new.perform(scheduled_message.id)
+
+ expect(conversation.messages.last.attachments.count).to eq(1)
+ end
+ end
+
+ it 'marks as failed on error' do
+ allow(Messages::MessageBuilder).to receive(:new).and_raise(StandardError, 'boom')
+
+ travel_to(3.minutes.from_now) do
+ described_class.new.perform(scheduled_message.id)
+
+ expect(scheduled_message.reload.status).to eq('failed')
+ end
+ end
+
+ it 'skips when not pending' do
+ draft = create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: author, status: :draft,
+ scheduled_at: nil)
+
+ travel_to(3.minutes.from_now) do
+ expect { described_class.new.perform(draft.id) }.not_to(change { conversation.messages.count })
+ end
+ end
+
+ it 'skips when not due' do
+ future = create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: author,
+ scheduled_at: 10.minutes.from_now)
+
+ travel_to(3.minutes.from_now) do
+ expect { described_class.new.perform(future.id) }.not_to(change { conversation.messages.count })
+ end
+ end
+ end
+end
diff --git a/spec/jobs/scheduled_messages/trigger_scheduled_messages_job_spec.rb b/spec/jobs/scheduled_messages/trigger_scheduled_messages_job_spec.rb
new file mode 100644
index 000000000..b3df18585
--- /dev/null
+++ b/spec/jobs/scheduled_messages/trigger_scheduled_messages_job_spec.rb
@@ -0,0 +1,36 @@
+require 'rails_helper'
+
+RSpec.describe ScheduledMessages::TriggerScheduledMessagesJob, type: :job do
+ let(:account) { create(:account) }
+ let(:author) { create(:user, account: account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:contact) { create(:contact, account: account) }
+ let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox) }
+
+ describe '#perform' do
+ it 'enqueues jobs for due scheduled messages only' do
+ due_same_minute = create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: author,
+ scheduled_at: 1.minute.from_now)
+ overdue = create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: author,
+ scheduled_at: 2.minutes.from_now)
+ future = create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: author,
+ scheduled_at: 5.minutes.from_now)
+ draft = create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: author,
+ scheduled_at: 3.minutes.from_now, status: :draft)
+
+ travel_to(3.minutes.from_now) do
+ clear_enqueued_jobs
+ described_class.new.perform
+
+ enqueued_ids = enqueued_jobs
+ .select { |job| job[:job] == ScheduledMessages::SendScheduledMessageJob }
+ .map { |job| job[:args].first }
+
+ expect(enqueued_ids).to contain_exactly(due_same_minute.id, overdue.id)
+ expect(enqueued_ids).not_to include(future.id)
+ expect(enqueued_ids).not_to include(draft.id)
+ end
+ end
+ end
+end
diff --git a/spec/listeners/action_cable_listener_spec.rb b/spec/listeners/action_cable_listener_spec.rb
index 71f03c24b..12cb1ed4d 100644
--- a/spec/listeners/action_cable_listener_spec.rb
+++ b/spec/listeners/action_cable_listener_spec.rb
@@ -211,4 +211,31 @@ describe ActionCableListener do
listener.conversation_updated(event)
end
end
+
+ shared_examples 'scheduled message event broadcast' do |method_name, event_name|
+ it 'broadcasts to account admins and inbox members' do
+ scheduled_message = create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: agent)
+ event = Events::Base.new(event_name, Time.zone.now, scheduled_message: scheduled_message)
+
+ expect(ActionCableBroadcastJob).to receive(:perform_later).with(
+ a_collection_containing_exactly(agent.pubsub_token, admin.pubsub_token),
+ event_name,
+ scheduled_message.push_event_data.merge(account_id: account.id)
+ )
+
+ listener.public_send(method_name, event)
+ end
+ end
+
+ describe '#scheduled_message_created' do
+ it_behaves_like 'scheduled message event broadcast', :scheduled_message_created, 'scheduled_message.created'
+ end
+
+ describe '#scheduled_message_updated' do
+ it_behaves_like 'scheduled message event broadcast', :scheduled_message_updated, 'scheduled_message.updated'
+ end
+
+ describe '#scheduled_message_deleted' do
+ it_behaves_like 'scheduled message event broadcast', :scheduled_message_deleted, 'scheduled_message.deleted'
+ end
end
diff --git a/spec/models/concerns/scheduled_message_handler_spec.rb b/spec/models/concerns/scheduled_message_handler_spec.rb
new file mode 100644
index 000000000..a83297641
--- /dev/null
+++ b/spec/models/concerns/scheduled_message_handler_spec.rb
@@ -0,0 +1,72 @@
+require 'rails_helper'
+
+RSpec.describe ScheduledMessageHandler do
+ let(:account) { create(:account) }
+ let(:author) { create(:user, account: account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:contact) { create(:contact, account: account) }
+ let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox) }
+
+ let(:scheduled_message) do
+ create(:scheduled_message, account: account, inbox: inbox, conversation: conversation, author: author)
+ end
+
+ let(:message) do
+ create(
+ :message,
+ account: account,
+ inbox: inbox,
+ conversation: conversation,
+ message_type: :outgoing,
+ additional_attributes: { 'scheduled_message_id' => scheduled_message.id }
+ )
+ end
+
+ describe '#update_scheduled_message_status' do
+ it 'marks scheduled message as sent when message status changes to delivered' do
+ message.update!(status: :delivered)
+ expect(scheduled_message.reload.status).to eq('sent')
+ end
+
+ it 'marks scheduled message as sent when message status changes to read' do
+ message.update!(status: :read)
+ expect(scheduled_message.reload.status).to eq('sent')
+ end
+
+ it 'marks scheduled message as failed when message status changes to failed' do
+ message.update!(status: :failed)
+ expect(scheduled_message.reload.status).to eq('failed')
+ end
+
+ it 'does not raise an error when message has no scheduled_message_id' do
+ message_without_scheduled = create(
+ :message,
+ account: account,
+ inbox: inbox,
+ conversation: conversation,
+ message_type: :outgoing
+ )
+
+ expect { message_without_scheduled.update!(status: :delivered) }.not_to raise_error
+ end
+ end
+
+ describe '#dispatch_scheduled_message_update' do
+ it 'dispatches SCHEDULED_MESSAGE_UPDATED event when scheduled message status is updated' do
+ allow(Rails.configuration.dispatcher).to receive(:dispatch).and_call_original
+
+ expect(Rails.configuration.dispatcher).to receive(:dispatch)
+ .with(Events::Types::SCHEDULED_MESSAGE_UPDATED, anything, scheduled_message: scheduled_message)
+
+ message.update!(status: :delivered)
+ end
+
+ it 'does not dispatch SCHEDULED_MESSAGE_UPDATED event when scheduled message status is not updated' do
+ expect(Rails.configuration.dispatcher).not_to receive(:dispatch)
+ .with(Events::Types::SCHEDULED_MESSAGE_UPDATED, anything, anything)
+
+ message.update!(content: 'Updated content')
+ end
+ end
+end
diff --git a/spec/models/scheduled_message_spec.rb b/spec/models/scheduled_message_spec.rb
new file mode 100644
index 000000000..96de6861a
--- /dev/null
+++ b/spec/models/scheduled_message_spec.rb
@@ -0,0 +1,238 @@
+require 'rails_helper'
+
+RSpec.describe ScheduledMessage, type: :model do
+ let(:account) { create(:account) }
+ let(:author) { create(:user, account: account) }
+ let(:automation_rule) { create(:automation_rule, account: account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:contact) { create(:contact, account: account) }
+ let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox) }
+
+ def build_scheduled_message(attrs = {})
+ ScheduledMessage.new({
+ account: account,
+ inbox: inbox,
+ conversation: conversation,
+ author: author,
+ content: 'Hello',
+ status: :pending,
+ scheduled_at: 1.hour.from_now
+ }.merge(attrs))
+ end
+
+ def create_scheduled_message(attrs = {})
+ build_scheduled_message(attrs).tap(&:save!)
+ end
+
+ describe 'validations' do
+ describe 'content' do
+ it 'requires content when no template params or attachment' do
+ scheduled_message = build_scheduled_message(content: nil, template_params: {})
+
+ expect(scheduled_message).not_to be_valid
+ expect(scheduled_message.errors[:content]).to be_present
+ end
+
+ it 'allows template params without content' do
+ scheduled_message = build_scheduled_message(content: nil, template_params: { id: '123456789', name: 'test_template' })
+
+ expect(scheduled_message).to be_valid
+ end
+
+ it 'allows attachment without content' do
+ scheduled_message = build_scheduled_message(content: nil, template_params: {})
+ scheduled_message.attachment.attach(
+ io: Rails.root.join('spec/assets/avatar.png').open,
+ filename: 'avatar.png',
+ content_type: 'image/png'
+ )
+
+ expect(scheduled_message).to be_valid
+ end
+ end
+
+ describe 'author' do
+ it 'accepts automation rules as author' do
+ scheduled_message = build_scheduled_message(author: automation_rule)
+
+ expect(scheduled_message).to be_valid
+ end
+ end
+
+ describe 'status on create' do
+ it 'allows creating with draft status' do
+ scheduled_message = build_scheduled_message(status: :draft, scheduled_at: nil)
+
+ expect(scheduled_message).to be_valid
+ end
+
+ it 'allows creating with pending status' do
+ scheduled_message = build_scheduled_message
+
+ expect(scheduled_message).to be_valid
+ end
+
+ it 'does not allow creating with sent status' do
+ scheduled_message = build_scheduled_message(status: :sent)
+
+ expect(scheduled_message).not_to be_valid
+ expect(scheduled_message.errors[:status]).to be_present
+ end
+
+ it 'does not allow creating with failed status' do
+ scheduled_message = build_scheduled_message(status: :failed)
+
+ expect(scheduled_message).not_to be_valid
+ expect(scheduled_message.errors[:status]).to be_present
+ end
+ end
+
+ describe 'scheduled_at' do
+ it 'requires scheduled_at when status is pending' do
+ scheduled_message = build_scheduled_message(status: :pending, scheduled_at: nil)
+
+ expect(scheduled_message).not_to be_valid
+ expect(scheduled_message.errors[:scheduled_at]).to be_present
+ end
+
+ it 'does not require scheduled_at when status is draft' do
+ scheduled_message = build_scheduled_message(status: :draft, scheduled_at: nil)
+
+ expect(scheduled_message).to be_valid
+ end
+
+ it 'requires scheduled_at to be in the future when pending' do
+ scheduled_message = build_scheduled_message(status: :pending, scheduled_at: 1.minute.ago)
+
+ expect(scheduled_message).not_to be_valid
+ expect(scheduled_message.errors[:scheduled_at]).to include('must be in the future')
+ end
+
+ it 'validates future scheduled_at when changing status to pending' do
+ scheduled_message = create_scheduled_message(status: :draft, scheduled_at: nil)
+ scheduled_message.status = :pending
+ scheduled_message.scheduled_at = 1.minute.ago
+
+ expect(scheduled_message).not_to be_valid
+ expect(scheduled_message.errors[:scheduled_at]).to include('must be in the future')
+ end
+ end
+
+ describe 'editability' do
+ it 'allows editing draft messages' do
+ scheduled_message = create_scheduled_message(status: :draft, scheduled_at: nil)
+ scheduled_message.content = 'Updated content'
+
+ expect(scheduled_message).to be_valid
+ end
+
+ it 'allows editing pending messages' do
+ scheduled_message = create_scheduled_message
+ scheduled_message.content = 'Updated content'
+
+ expect(scheduled_message).to be_valid
+ end
+
+ it 'does not allow editing content of sent messages' do
+ scheduled_message = create_scheduled_message
+ scheduled_message.update!(status: :sent)
+
+ expect { scheduled_message.update!(content: 'Updated content') }.to raise_error(ActiveRecord::RecordInvalid) do |error|
+ expect(error.record.errors[:base]).to include('Scheduled message can only be modified while draft or pending')
+ end
+ end
+
+ it 'does not allow editing content of failed messages' do
+ scheduled_message = create_scheduled_message
+ scheduled_message.update!(status: :failed)
+
+ expect { scheduled_message.update!(content: 'Updated content') }.to raise_error(ActiveRecord::RecordInvalid) do |error|
+ expect(error.record.errors[:base]).to include('Scheduled message can only be modified while draft or pending')
+ end
+ end
+
+ it 'allows changing status from sent to failed' do
+ scheduled_message = create_scheduled_message
+ scheduled_message.update!(status: :sent)
+
+ expect { scheduled_message.update!(status: :failed) }.not_to raise_error
+ expect(scheduled_message.reload.status).to eq('failed')
+ end
+
+ it 'allows changing status from failed to sent' do
+ scheduled_message = create_scheduled_message
+ scheduled_message.update!(status: :failed)
+
+ expect { scheduled_message.update!(status: :sent) }.not_to raise_error
+ expect(scheduled_message.reload.status).to eq('sent')
+ end
+ end
+
+ describe 'destroy' do
+ it 'allows deleting draft messages' do
+ scheduled_message = create_scheduled_message(status: :draft, scheduled_at: nil)
+
+ expect { scheduled_message.destroy }.to change(described_class, :count).by(-1)
+ end
+
+ it 'allows deleting pending messages' do
+ scheduled_message = create_scheduled_message
+
+ expect { scheduled_message.destroy }.to change(described_class, :count).by(-1)
+ end
+
+ it 'does not allow deleting sent messages' do
+ scheduled_message = create_scheduled_message
+ scheduled_message.update!(status: :sent)
+
+ expect { scheduled_message.destroy }.not_to change(described_class, :count)
+ expect(scheduled_message.errors[:base]).to include('Cannot delete a scheduled message that has already been sent or failed')
+ end
+
+ it 'does not allow deleting failed messages' do
+ scheduled_message = create_scheduled_message
+ scheduled_message.update!(status: :failed)
+
+ expect { scheduled_message.destroy }.not_to change(described_class, :count)
+ expect(scheduled_message.errors[:base]).to include('Cannot delete a scheduled message that has already been sent or failed')
+ end
+ end
+ end
+
+ describe '.due_for_sending' do
+ it 'returns only pending messages scheduled up to the current minute' do
+ freeze_time
+
+ due_same_minute = create_scheduled_message(
+ scheduled_at: 1.minute.from_now
+ )
+ overdue = create_scheduled_message(
+ scheduled_at: 2.minutes.from_now
+ )
+ create_scheduled_message(
+ content: 'Future message',
+ scheduled_at: 10.minutes.from_now
+ )
+ create_scheduled_message(
+ scheduled_at: 1.minute.from_now,
+ status: :draft
+ )
+
+ sent_message = create_scheduled_message(
+ scheduled_at: 1.minute.from_now
+ )
+ sent_message.update!(status: :sent)
+
+ failed_message = create_scheduled_message(
+ scheduled_at: 1.minute.from_now
+ )
+ failed_message.update!(status: :failed)
+
+ # NOTE: Travel to a time where due_same_minute and overdue are due but not_due_yet is not
+ travel_to(5.minutes.from_now)
+
+ expect(described_class.due_for_sending).to contain_exactly(due_same_minute, overdue)
+ end
+ end
+end
diff --git a/spec/services/action_service_spec.rb b/spec/services/action_service_spec.rb
index 95b1e6ecf..57dbe44cd 100644
--- a/spec/services/action_service_spec.rb
+++ b/spec/services/action_service_spec.rb
@@ -92,4 +92,33 @@ describe ActionService do
end
end
end
+
+ describe '#create_scheduled_message' do
+ it 'creates scheduled message with content and delay' do
+ conversation = create(:conversation, account: account)
+ user = create(:user, account: account)
+ Current.user = user
+ action_service = described_class.new(conversation)
+
+ action_service.create_scheduled_message([{ content: 'Hello', delay_minutes: 10 }])
+
+ scheduled_message = conversation.scheduled_messages.last
+
+ expect(scheduled_message.content).to eq('Hello')
+ expect(scheduled_message.status).to eq('pending')
+ expect(scheduled_message.scheduled_at).to be_within(1.minute).of(10.minutes.from_now)
+ end
+
+ it 'attaches blob when blob_id is provided' do
+ conversation = create(:conversation, account: account)
+ user = create(:user, account: account)
+ Current.user = user
+ action_service = described_class.new(conversation)
+ blob = ActiveStorage::Blob.create_and_upload!(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png')
+
+ action_service.create_scheduled_message([{ content: 'With attachment', delay_minutes: 5, blob_id: blob.id }])
+
+ expect(conversation.scheduled_messages.last.attachment).to be_attached
+ end
+ end
end
diff --git a/spec/services/automation_rules/action_service_spec.rb b/spec/services/automation_rules/action_service_spec.rb
index 294ad3ced..5557f445a 100644
--- a/spec/services/automation_rules/action_service_spec.rb
+++ b/spec/services/automation_rules/action_service_spec.rb
@@ -178,5 +178,22 @@ RSpec.describe AutomationRules::ActionService do
described_class.new(rule, account, conversation).perform
end
end
+
+ describe '#perform with create_scheduled_message action' do
+ it 'creates scheduled message with attachment from rule files' do
+ rule.files.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
+ rule.save!
+ rule.actions = [{ action_name: 'create_scheduled_message',
+ action_params: [{ content: 'Scheduled', delay_minutes: 5, blob_id: rule.files.first.blob_id }] }]
+
+ expect { described_class.new(rule, account, conversation).perform }
+ .to change { conversation.scheduled_messages.count }.by(1)
+
+ scheduled_message = conversation.scheduled_messages.last
+ expect(scheduled_message.content).to eq('Scheduled')
+ expect(scheduled_message.author).to eq(rule)
+ expect(scheduled_message.attachment).to be_attached
+ end
+ end
end
end
diff --git a/swagger/definitions/index.yml b/swagger/definitions/index.yml
index fd9cc1664..1f132453f 100644
--- a/swagger/definitions/index.yml
+++ b/swagger/definitions/index.yml
@@ -40,6 +40,8 @@ conversation:
$ref: ./resource/conversation.yml
message:
$ref: ./resource/message.yml
+scheduled_message:
+ $ref: ./resource/scheduled_message.yml
user:
$ref: ./resource/user.yml
agent:
@@ -132,6 +134,10 @@ conversation_create_payload:
conversation_message_create_payload:
$ref: ./request/conversation/create_message_payload.yml
+## Scheduled Message
+scheduled_message_create_update_payload:
+ $ref: ./request/scheduled_message/create_update_payload.yml
+
# Inbox
inbox_create_payload:
$ref: ./request/inbox/create_payload.yml
diff --git a/swagger/definitions/request/scheduled_message/create_update_payload.yml b/swagger/definitions/request/scheduled_message/create_update_payload.yml
new file mode 100644
index 000000000..b26386d8a
--- /dev/null
+++ b/swagger/definitions/request/scheduled_message/create_update_payload.yml
@@ -0,0 +1,30 @@
+type: object
+properties:
+ content:
+ type: string
+ description: The text content of the scheduled message
+ example: "Hello! This is a scheduled message."
+ scheduled_at:
+ type: string
+ format: date-time
+ description: ISO 8601 datetime when the message should be sent
+ example: "2026-02-01T10:00:00Z"
+ status:
+ type: string
+ enum: ["draft", "pending"]
+ description: The status of the scheduled message. Use 'draft' to save without scheduling, 'pending' to schedule the message.
+ example: "pending"
+ attachment:
+ type: string
+ format: binary
+ description: File attachment for the scheduled message
+ template_params:
+ type: object
+ description: Template parameters for WhatsApp template messages
+ example:
+ name: "order_confirmation"
+ category: "MARKETING"
+ language: "en"
+ processed_params:
+ body:
+ "1": "121212"
diff --git a/swagger/definitions/resource/scheduled_message.yml b/swagger/definitions/resource/scheduled_message.yml
new file mode 100644
index 000000000..548747229
--- /dev/null
+++ b/swagger/definitions/resource/scheduled_message.yml
@@ -0,0 +1,67 @@
+type: object
+properties:
+ id:
+ type: integer
+ description: ID of the scheduled message
+ content:
+ type: string
+ description: The text content of the scheduled message
+ inbox_id:
+ type: integer
+ description: ID of the inbox
+ conversation_id:
+ type: integer
+ description: Display ID of the conversation
+ account_id:
+ type: integer
+ description: ID of the account
+ status:
+ type: string
+ enum: ["draft", "pending", "sent", "failed"]
+ description: The status of the scheduled message
+ scheduled_at:
+ type: integer
+ description: Unix timestamp when the message is scheduled to be sent
+ template_params:
+ type: object
+ description: Template parameters for WhatsApp template messages
+ author_id:
+ type: integer
+ description: ID of the author who created the scheduled message
+ author_type:
+ type: string
+ description: Type of the author (User, Contact, etc.)
+ created_at:
+ type: integer
+ description: Unix timestamp when the scheduled message was created
+ updated_at:
+ type: integer
+ description: Unix timestamp when the scheduled message was last updated
+ author:
+ type: object
+ description: The author object (User/Agent details)
+ attachment:
+ type: object
+ description: The file attachment object if any
+ properties:
+ id:
+ type: integer
+ description: ID of the attachment
+ scheduled_message_id:
+ type: integer
+ description: ID of the scheduled message this attachment belongs to
+ file_type:
+ type: string
+ description: MIME type of the file
+ account_id:
+ type: integer
+ description: ID of the account
+ file_url:
+ type: string
+ description: URL to access the file
+ blob_id:
+ type: string
+ description: Signed blob ID for the attachment
+ filename:
+ type: string
+ description: Name of the file
diff --git a/swagger/index.yml b/swagger/index.yml
index dd460d111..8675ec17d 100644
--- a/swagger/index.yml
+++ b/swagger/index.yml
@@ -69,6 +69,8 @@ tags:
description: Third-party integrations
- name: Messages
description: Message management APIs
+ - name: Scheduled Messages
+ description: Scheduled message management APIs
- name: Profile
description: User profile APIs
- name: Reports
@@ -113,6 +115,7 @@ x-tagGroups:
- Inboxes
- Integrations
- Messages
+ - Scheduled Messages
- Profile
- Reports
- Teams
diff --git a/swagger/parameters/index.yml b/swagger/parameters/index.yml
index d9cb6872c..fac55ec96 100644
--- a/swagger/parameters/index.yml
+++ b/swagger/parameters/index.yml
@@ -34,6 +34,9 @@ webhook_id:
message_id:
$ref: ./message_id.yml
+scheduled_message_id:
+ $ref: ./scheduled_message_id.yml
+
page:
$ref: ./page.yml
@@ -53,4 +56,4 @@ public_contact_identifier:
$ref: ./public/contact_identifier.yml
portal_id:
- $ref: ./portal_id.yml
\ No newline at end of file
+ $ref: ./portal_id.yml
diff --git a/swagger/parameters/scheduled_message_id.yml b/swagger/parameters/scheduled_message_id.yml
new file mode 100644
index 000000000..eba79258b
--- /dev/null
+++ b/swagger/parameters/scheduled_message_id.yml
@@ -0,0 +1,6 @@
+in: path
+name: scheduled_message_id
+schema:
+ type: integer
+required: true
+description: The numeric ID of the scheduled message
diff --git a/swagger/paths/application/conversation/scheduled_messages/create.yml b/swagger/paths/application/conversation/scheduled_messages/create.yml
new file mode 100644
index 000000000..f2094964e
--- /dev/null
+++ b/swagger/paths/application/conversation/scheduled_messages/create.yml
@@ -0,0 +1,53 @@
+tags:
+ - Scheduled Messages
+operationId: create-scheduled-message
+summary: Create a Scheduled Message
+description: |
+ Create a new scheduled message in a conversation.
+
+ The message will be automatically sent at the specified `scheduled_at` time.
+ Use status 'draft' to save the message without scheduling, or 'pending' to schedule it.
+
+ ## WhatsApp Template Messages
+
+ For WhatsApp channels, you can schedule template messages using the `template_params` field.
+ Templates must be pre-approved in WhatsApp Business Manager.
+security:
+ - userApiKey: []
+requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/scheduled_message_create_update_payload'
+responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/scheduled_message'
+ '404':
+ description: Conversation not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '403':
+ description: Access denied
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '422':
+ description: Unprocessable Entity - validation errors
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/application/conversation/scheduled_messages/delete.yml b/swagger/paths/application/conversation/scheduled_messages/delete.yml
new file mode 100644
index 000000000..60e8af3b7
--- /dev/null
+++ b/swagger/paths/application/conversation/scheduled_messages/delete.yml
@@ -0,0 +1,42 @@
+tags:
+ - Scheduled Messages
+operationId: delete-scheduled-message
+summary: Delete a Scheduled Message
+description: |
+ Delete a scheduled message.
+
+ Only messages with status 'draft' or 'pending' can be deleted.
+ Messages that have been sent or failed cannot be deleted.
+security:
+ - userApiKey: []
+responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/scheduled_message'
+ '404':
+ description: Scheduled message not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '403':
+ description: Access denied
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '422':
+ description: Unprocessable Entity - cannot delete sent/failed message
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/application/conversation/scheduled_messages/index.yml b/swagger/paths/application/conversation/scheduled_messages/index.yml
new file mode 100644
index 000000000..e0e1c25f5
--- /dev/null
+++ b/swagger/paths/application/conversation/scheduled_messages/index.yml
@@ -0,0 +1,38 @@
+tags:
+ - Scheduled Messages
+operationId: list-scheduled-messages
+summary: List Scheduled Messages
+security:
+ - userApiKey: []
+description: List all scheduled messages for a conversation
+responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ payload:
+ type: array
+ description: Array of scheduled messages
+ items:
+ $ref: '#/components/schemas/scheduled_message'
+ '404':
+ description: Conversation not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '403':
+ description: Access denied
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/application/conversation/scheduled_messages/update.yml b/swagger/paths/application/conversation/scheduled_messages/update.yml
new file mode 100644
index 000000000..970b77bf3
--- /dev/null
+++ b/swagger/paths/application/conversation/scheduled_messages/update.yml
@@ -0,0 +1,48 @@
+tags:
+ - Scheduled Messages
+operationId: update-scheduled-message
+summary: Update a Scheduled Message
+description: |
+ Update an existing scheduled message.
+
+ Only messages with status 'draft' or 'pending' can be modified.
+ Messages that have been sent or failed cannot be updated.
+security:
+ - userApiKey: []
+requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/scheduled_message_create_update_payload'
+responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/scheduled_message'
+ '404':
+ description: Scheduled message not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '403':
+ description: Access denied
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
+ '422':
+ description: Unprocessable Entity - validation errors (e.g., message already sent)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/bad_request_error'
diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml
index 2e7c5514e..a22847410 100644
--- a/swagger/paths/index.yml
+++ b/swagger/paths/index.yml
@@ -404,6 +404,27 @@
get:
$ref: ./application/conversation/reporting_events.yml
+# Scheduled Messages
+
+/api/v1/accounts/{account_id}/conversations/{conversation_id}/scheduled_messages:
+ parameters:
+ - $ref: '#/components/parameters/account_id'
+ - $ref: '#/components/parameters/conversation_id'
+ get:
+ $ref: ./application/conversation/scheduled_messages/index.yml
+ post:
+ $ref: ./application/conversation/scheduled_messages/create.yml
+
+/api/v1/accounts/{account_id}/conversations/{conversation_id}/scheduled_messages/{scheduled_message_id}:
+ parameters:
+ - $ref: '#/components/parameters/account_id'
+ - $ref: '#/components/parameters/conversation_id'
+ - $ref: '#/components/parameters/scheduled_message_id'
+ patch:
+ $ref: ./application/conversation/scheduled_messages/update.yml
+ delete:
+ $ref: ./application/conversation/scheduled_messages/delete.yml
+
# Inboxes
/api/v1/accounts/{account_id}/inboxes:
$ref: ./application/inboxes/index.yml
diff --git a/swagger/swagger.json b/swagger/swagger.json
index 9f280978f..e153329a1 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -1208,10 +1208,13 @@
"name": "typing_status",
"in": "query",
"required": true,
- "schema": {
- "type": "string"
- },
- "description": "Typing status, either 'on' or 'off'"
+ "type": "string",
+ "enum": [
+ "on",
+ "recording",
+ "off"
+ ],
+ "description": "Typing status."
}
],
"requestBody": {
@@ -1225,6 +1228,7 @@
"type": "string",
"enum": [
"on",
+ "recording",
"off"
],
"description": "The typing status to set",
@@ -5287,6 +5291,308 @@
}
}
},
+ "/api/v1/accounts/{account_id}/conversations/{conversation_id}/scheduled_messages": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "$ref": "#/components/parameters/conversation_id"
+ }
+ ],
+ "get": {
+ "tags": [
+ "Scheduled Messages"
+ ],
+ "operationId": "list-scheduled-messages",
+ "summary": "List Scheduled Messages",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "description": "List all scheduled messages for a conversation",
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "payload": {
+ "type": "array",
+ "description": "Array of scheduled messages",
+ "items": {
+ "$ref": "#/components/schemas/scheduled_message"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Conversation not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Access denied",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "Scheduled Messages"
+ ],
+ "operationId": "create-scheduled-message",
+ "summary": "Create a Scheduled Message",
+ "description": "Create a new scheduled message in a conversation.\n\nThe message will be automatically sent at the specified `scheduled_at` time.\nUse status 'draft' to save the message without scheduling, or 'pending' to schedule it.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can schedule template messages using the `template_params` field.\nTemplates must be pre-approved in WhatsApp Business Manager.\n",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/scheduled_message_create_update_payload"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/scheduled_message"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Conversation not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Access denied",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity - validation errors",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/accounts/{account_id}/conversations/{conversation_id}/scheduled_messages/{scheduled_message_id}": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "$ref": "#/components/parameters/conversation_id"
+ },
+ {
+ "$ref": "#/components/parameters/scheduled_message_id"
+ }
+ ],
+ "patch": {
+ "tags": [
+ "Scheduled Messages"
+ ],
+ "operationId": "update-scheduled-message",
+ "summary": "Update a Scheduled Message",
+ "description": "Update an existing scheduled message.\n\nOnly messages with status 'draft' or 'pending' can be modified.\nMessages that have been sent or failed cannot be updated.\n",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/scheduled_message_create_update_payload"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/scheduled_message"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Scheduled message not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Access denied",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity - validation errors (e.g., message already sent)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Scheduled Messages"
+ ],
+ "operationId": "delete-scheduled-message",
+ "summary": "Delete a Scheduled Message",
+ "description": "Delete a scheduled message.\n\nOnly messages with status 'draft' or 'pending' can be deleted.\nMessages that have been sent or failed cannot be deleted.\n",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/scheduled_message"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Scheduled message not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Access denied",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity - cannot delete sent/failed message",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/accounts/{account_id}/inboxes": {
"get": {
"tags": [
@@ -8799,6 +9105,103 @@
}
}
},
+ "scheduled_message": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "ID of the scheduled message"
+ },
+ "content": {
+ "type": "string",
+ "description": "The text content of the scheduled message"
+ },
+ "inbox_id": {
+ "type": "integer",
+ "description": "ID of the inbox"
+ },
+ "conversation_id": {
+ "type": "integer",
+ "description": "Display ID of the conversation"
+ },
+ "account_id": {
+ "type": "integer",
+ "description": "ID of the account"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "draft",
+ "pending",
+ "sent",
+ "failed"
+ ],
+ "description": "The status of the scheduled message"
+ },
+ "scheduled_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the message is scheduled to be sent"
+ },
+ "template_params": {
+ "type": "object",
+ "description": "Template parameters for WhatsApp template messages"
+ },
+ "author_id": {
+ "type": "integer",
+ "description": "ID of the author who created the scheduled message"
+ },
+ "author_type": {
+ "type": "string",
+ "description": "Type of the author (User, Contact, etc.)"
+ },
+ "created_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the scheduled message was created"
+ },
+ "updated_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the scheduled message was last updated"
+ },
+ "author": {
+ "type": "object",
+ "description": "The author object (User/Agent details)"
+ },
+ "attachment": {
+ "type": "object",
+ "description": "The file attachment object if any",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "ID of the attachment"
+ },
+ "scheduled_message_id": {
+ "type": "integer",
+ "description": "ID of the scheduled message this attachment belongs to"
+ },
+ "file_type": {
+ "type": "string",
+ "description": "MIME type of the file"
+ },
+ "account_id": {
+ "type": "integer",
+ "description": "ID of the account"
+ },
+ "file_url": {
+ "type": "string",
+ "description": "URL to access the file"
+ },
+ "blob_id": {
+ "type": "string",
+ "description": "Signed blob ID for the attachment"
+ },
+ "filename": {
+ "type": "string",
+ "description": "Name of the file"
+ }
+ }
+ }
+ }
+ },
"user": {
"type": "object",
"properties": {
@@ -10589,6 +10992,50 @@
}
}
},
+ "scheduled_message_create_update_payload": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "string",
+ "description": "The text content of the scheduled message",
+ "example": "Hello! This is a scheduled message."
+ },
+ "scheduled_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 datetime when the message should be sent",
+ "example": "2026-02-01T10:00:00Z"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "draft",
+ "pending"
+ ],
+ "description": "The status of the scheduled message. Use 'draft' to save without scheduling, 'pending' to schedule the message.",
+ "example": "pending"
+ },
+ "attachment": {
+ "type": "string",
+ "format": "binary",
+ "description": "File attachment for the scheduled message"
+ },
+ "template_params": {
+ "type": "object",
+ "description": "Template parameters for WhatsApp template messages",
+ "example": {
+ "name": "order_confirmation",
+ "category": "MARKETING",
+ "language": "en",
+ "processed_params": {
+ "body": {
+ "1": "121212"
+ }
+ }
+ }
+ }
+ }
+ },
"inbox_create_payload": {
"type": "object",
"properties": {
@@ -12645,6 +13092,15 @@
"required": true,
"description": "The numeric ID of the message"
},
+ "scheduled_message_id": {
+ "in": "path",
+ "name": "scheduled_message_id",
+ "schema": {
+ "type": "integer"
+ },
+ "required": true,
+ "description": "The numeric ID of the scheduled message"
+ },
"page": {
"in": "query",
"name": "page",
@@ -12815,6 +13271,10 @@
"name": "Messages",
"description": "Message management APIs"
},
+ {
+ "name": "Scheduled Messages",
+ "description": "Scheduled message management APIs"
+ },
{
"name": "Profile",
"description": "User profile APIs"
@@ -12884,6 +13344,7 @@
"Inboxes",
"Integrations",
"Messages",
+ "Scheduled Messages",
"Profile",
"Reports",
"Teams",
@@ -12907,4 +13368,4 @@
]
}
]
-}
+}
\ No newline at end of file
diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json
index ef5ec5389..6a00d7db5 100644
--- a/swagger/tag_groups/application_swagger.json
+++ b/swagger/tag_groups/application_swagger.json
@@ -3830,6 +3830,308 @@
}
}
},
+ "/api/v1/accounts/{account_id}/conversations/{conversation_id}/scheduled_messages": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "$ref": "#/components/parameters/conversation_id"
+ }
+ ],
+ "get": {
+ "tags": [
+ "Scheduled Messages"
+ ],
+ "operationId": "list-scheduled-messages",
+ "summary": "List Scheduled Messages",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "description": "List all scheduled messages for a conversation",
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "payload": {
+ "type": "array",
+ "description": "Array of scheduled messages",
+ "items": {
+ "$ref": "#/components/schemas/scheduled_message"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Conversation not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Access denied",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "Scheduled Messages"
+ ],
+ "operationId": "create-scheduled-message",
+ "summary": "Create a Scheduled Message",
+ "description": "Create a new scheduled message in a conversation.\n\nThe message will be automatically sent at the specified `scheduled_at` time.\nUse status 'draft' to save the message without scheduling, or 'pending' to schedule it.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can schedule template messages using the `template_params` field.\nTemplates must be pre-approved in WhatsApp Business Manager.\n",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/scheduled_message_create_update_payload"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/scheduled_message"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Conversation not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Access denied",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity - validation errors",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/accounts/{account_id}/conversations/{conversation_id}/scheduled_messages/{scheduled_message_id}": {
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/account_id"
+ },
+ {
+ "$ref": "#/components/parameters/conversation_id"
+ },
+ {
+ "$ref": "#/components/parameters/scheduled_message_id"
+ }
+ ],
+ "patch": {
+ "tags": [
+ "Scheduled Messages"
+ ],
+ "operationId": "update-scheduled-message",
+ "summary": "Update a Scheduled Message",
+ "description": "Update an existing scheduled message.\n\nOnly messages with status 'draft' or 'pending' can be modified.\nMessages that have been sent or failed cannot be updated.\n",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/scheduled_message_create_update_payload"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/scheduled_message"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Scheduled message not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Access denied",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity - validation errors (e.g., message already sent)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Scheduled Messages"
+ ],
+ "operationId": "delete-scheduled-message",
+ "summary": "Delete a Scheduled Message",
+ "description": "Delete a scheduled message.\n\nOnly messages with status 'draft' or 'pending' can be deleted.\nMessages that have been sent or failed cannot be deleted.\n",
+ "security": [
+ {
+ "userApiKey": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/scheduled_message"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Scheduled message not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Access denied",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity - cannot delete sent/failed message",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/bad_request_error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/accounts/{account_id}/inboxes": {
"get": {
"tags": [
@@ -7306,6 +7608,103 @@
}
}
},
+ "scheduled_message": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "ID of the scheduled message"
+ },
+ "content": {
+ "type": "string",
+ "description": "The text content of the scheduled message"
+ },
+ "inbox_id": {
+ "type": "integer",
+ "description": "ID of the inbox"
+ },
+ "conversation_id": {
+ "type": "integer",
+ "description": "Display ID of the conversation"
+ },
+ "account_id": {
+ "type": "integer",
+ "description": "ID of the account"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "draft",
+ "pending",
+ "sent",
+ "failed"
+ ],
+ "description": "The status of the scheduled message"
+ },
+ "scheduled_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the message is scheduled to be sent"
+ },
+ "template_params": {
+ "type": "object",
+ "description": "Template parameters for WhatsApp template messages"
+ },
+ "author_id": {
+ "type": "integer",
+ "description": "ID of the author who created the scheduled message"
+ },
+ "author_type": {
+ "type": "string",
+ "description": "Type of the author (User, Contact, etc.)"
+ },
+ "created_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the scheduled message was created"
+ },
+ "updated_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the scheduled message was last updated"
+ },
+ "author": {
+ "type": "object",
+ "description": "The author object (User/Agent details)"
+ },
+ "attachment": {
+ "type": "object",
+ "description": "The file attachment object if any",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "ID of the attachment"
+ },
+ "scheduled_message_id": {
+ "type": "integer",
+ "description": "ID of the scheduled message this attachment belongs to"
+ },
+ "file_type": {
+ "type": "string",
+ "description": "MIME type of the file"
+ },
+ "account_id": {
+ "type": "integer",
+ "description": "ID of the account"
+ },
+ "file_url": {
+ "type": "string",
+ "description": "URL to access the file"
+ },
+ "blob_id": {
+ "type": "string",
+ "description": "Signed blob ID for the attachment"
+ },
+ "filename": {
+ "type": "string",
+ "description": "Name of the file"
+ }
+ }
+ }
+ }
+ },
"user": {
"type": "object",
"properties": {
@@ -9096,6 +9495,50 @@
}
}
},
+ "scheduled_message_create_update_payload": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "string",
+ "description": "The text content of the scheduled message",
+ "example": "Hello! This is a scheduled message."
+ },
+ "scheduled_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 datetime when the message should be sent",
+ "example": "2026-02-01T10:00:00Z"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "draft",
+ "pending"
+ ],
+ "description": "The status of the scheduled message. Use 'draft' to save without scheduling, 'pending' to schedule the message.",
+ "example": "pending"
+ },
+ "attachment": {
+ "type": "string",
+ "format": "binary",
+ "description": "File attachment for the scheduled message"
+ },
+ "template_params": {
+ "type": "object",
+ "description": "Template parameters for WhatsApp template messages",
+ "example": {
+ "name": "order_confirmation",
+ "category": "MARKETING",
+ "language": "en",
+ "processed_params": {
+ "body": {
+ "1": "121212"
+ }
+ }
+ }
+ }
+ }
+ },
"inbox_create_payload": {
"type": "object",
"properties": {
@@ -11152,6 +11595,15 @@
"required": true,
"description": "The numeric ID of the message"
},
+ "scheduled_message_id": {
+ "in": "path",
+ "name": "scheduled_message_id",
+ "schema": {
+ "type": "integer"
+ },
+ "required": true,
+ "description": "The numeric ID of the scheduled message"
+ },
"page": {
"in": "query",
"name": "page",
@@ -11306,6 +11758,10 @@
"name": "Messages",
"description": "Message management APIs"
},
+ {
+ "name": "Scheduled Messages",
+ "description": "Scheduled message management APIs"
+ },
{
"name": "Profile",
"description": "User profile APIs"
@@ -11359,6 +11815,7 @@
"Inboxes",
"Integrations",
"Messages",
+ "Scheduled Messages",
"Profile",
"Reports",
"Teams",
diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json
index bcf4bb178..c065df463 100644
--- a/swagger/tag_groups/client_swagger.json
+++ b/swagger/tag_groups/client_swagger.json
@@ -378,10 +378,13 @@
"name": "typing_status",
"in": "query",
"required": true,
- "schema": {
- "type": "string"
- },
- "description": "Typing status, either 'on' or 'off'"
+ "type": "string",
+ "enum": [
+ "on",
+ "recording",
+ "off"
+ ],
+ "description": "Typing status."
}
],
"requestBody": {
@@ -395,6 +398,7 @@
"type": "string",
"enum": [
"on",
+ "recording",
"off"
],
"description": "The typing status to set",
@@ -1442,6 +1446,103 @@
}
}
},
+ "scheduled_message": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "ID of the scheduled message"
+ },
+ "content": {
+ "type": "string",
+ "description": "The text content of the scheduled message"
+ },
+ "inbox_id": {
+ "type": "integer",
+ "description": "ID of the inbox"
+ },
+ "conversation_id": {
+ "type": "integer",
+ "description": "Display ID of the conversation"
+ },
+ "account_id": {
+ "type": "integer",
+ "description": "ID of the account"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "draft",
+ "pending",
+ "sent",
+ "failed"
+ ],
+ "description": "The status of the scheduled message"
+ },
+ "scheduled_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the message is scheduled to be sent"
+ },
+ "template_params": {
+ "type": "object",
+ "description": "Template parameters for WhatsApp template messages"
+ },
+ "author_id": {
+ "type": "integer",
+ "description": "ID of the author who created the scheduled message"
+ },
+ "author_type": {
+ "type": "string",
+ "description": "Type of the author (User, Contact, etc.)"
+ },
+ "created_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the scheduled message was created"
+ },
+ "updated_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the scheduled message was last updated"
+ },
+ "author": {
+ "type": "object",
+ "description": "The author object (User/Agent details)"
+ },
+ "attachment": {
+ "type": "object",
+ "description": "The file attachment object if any",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "ID of the attachment"
+ },
+ "scheduled_message_id": {
+ "type": "integer",
+ "description": "ID of the scheduled message this attachment belongs to"
+ },
+ "file_type": {
+ "type": "string",
+ "description": "MIME type of the file"
+ },
+ "account_id": {
+ "type": "integer",
+ "description": "ID of the account"
+ },
+ "file_url": {
+ "type": "string",
+ "description": "URL to access the file"
+ },
+ "blob_id": {
+ "type": "string",
+ "description": "Signed blob ID for the attachment"
+ },
+ "filename": {
+ "type": "string",
+ "description": "Name of the file"
+ }
+ }
+ }
+ }
+ },
"user": {
"type": "object",
"properties": {
@@ -3232,6 +3333,50 @@
}
}
},
+ "scheduled_message_create_update_payload": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "string",
+ "description": "The text content of the scheduled message",
+ "example": "Hello! This is a scheduled message."
+ },
+ "scheduled_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 datetime when the message should be sent",
+ "example": "2026-02-01T10:00:00Z"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "draft",
+ "pending"
+ ],
+ "description": "The status of the scheduled message. Use 'draft' to save without scheduling, 'pending' to schedule the message.",
+ "example": "pending"
+ },
+ "attachment": {
+ "type": "string",
+ "format": "binary",
+ "description": "File attachment for the scheduled message"
+ },
+ "template_params": {
+ "type": "object",
+ "description": "Template parameters for WhatsApp template messages",
+ "example": {
+ "name": "order_confirmation",
+ "category": "MARKETING",
+ "language": "en",
+ "processed_params": {
+ "body": {
+ "1": "121212"
+ }
+ }
+ }
+ }
+ }
+ },
"inbox_create_payload": {
"type": "object",
"properties": {
@@ -5288,6 +5433,15 @@
"required": true,
"description": "The numeric ID of the message"
},
+ "scheduled_message_id": {
+ "in": "path",
+ "name": "scheduled_message_id",
+ "schema": {
+ "type": "integer"
+ },
+ "required": true,
+ "description": "The numeric ID of the scheduled message"
+ },
"page": {
"in": "query",
"name": "page",
@@ -5431,6 +5585,7 @@
"Inboxes",
"Integrations",
"Messages",
+ "Scheduled Messages",
"Profile",
"Reports",
"Teams",
diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json
index 01d1adc46..31ae88827 100644
--- a/swagger/tag_groups/other_swagger.json
+++ b/swagger/tag_groups/other_swagger.json
@@ -857,6 +857,103 @@
}
}
},
+ "scheduled_message": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "ID of the scheduled message"
+ },
+ "content": {
+ "type": "string",
+ "description": "The text content of the scheduled message"
+ },
+ "inbox_id": {
+ "type": "integer",
+ "description": "ID of the inbox"
+ },
+ "conversation_id": {
+ "type": "integer",
+ "description": "Display ID of the conversation"
+ },
+ "account_id": {
+ "type": "integer",
+ "description": "ID of the account"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "draft",
+ "pending",
+ "sent",
+ "failed"
+ ],
+ "description": "The status of the scheduled message"
+ },
+ "scheduled_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the message is scheduled to be sent"
+ },
+ "template_params": {
+ "type": "object",
+ "description": "Template parameters for WhatsApp template messages"
+ },
+ "author_id": {
+ "type": "integer",
+ "description": "ID of the author who created the scheduled message"
+ },
+ "author_type": {
+ "type": "string",
+ "description": "Type of the author (User, Contact, etc.)"
+ },
+ "created_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the scheduled message was created"
+ },
+ "updated_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the scheduled message was last updated"
+ },
+ "author": {
+ "type": "object",
+ "description": "The author object (User/Agent details)"
+ },
+ "attachment": {
+ "type": "object",
+ "description": "The file attachment object if any",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "ID of the attachment"
+ },
+ "scheduled_message_id": {
+ "type": "integer",
+ "description": "ID of the scheduled message this attachment belongs to"
+ },
+ "file_type": {
+ "type": "string",
+ "description": "MIME type of the file"
+ },
+ "account_id": {
+ "type": "integer",
+ "description": "ID of the account"
+ },
+ "file_url": {
+ "type": "string",
+ "description": "URL to access the file"
+ },
+ "blob_id": {
+ "type": "string",
+ "description": "Signed blob ID for the attachment"
+ },
+ "filename": {
+ "type": "string",
+ "description": "Name of the file"
+ }
+ }
+ }
+ }
+ },
"user": {
"type": "object",
"properties": {
@@ -2647,6 +2744,50 @@
}
}
},
+ "scheduled_message_create_update_payload": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "string",
+ "description": "The text content of the scheduled message",
+ "example": "Hello! This is a scheduled message."
+ },
+ "scheduled_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 datetime when the message should be sent",
+ "example": "2026-02-01T10:00:00Z"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "draft",
+ "pending"
+ ],
+ "description": "The status of the scheduled message. Use 'draft' to save without scheduling, 'pending' to schedule the message.",
+ "example": "pending"
+ },
+ "attachment": {
+ "type": "string",
+ "format": "binary",
+ "description": "File attachment for the scheduled message"
+ },
+ "template_params": {
+ "type": "object",
+ "description": "Template parameters for WhatsApp template messages",
+ "example": {
+ "name": "order_confirmation",
+ "category": "MARKETING",
+ "language": "en",
+ "processed_params": {
+ "body": {
+ "1": "121212"
+ }
+ }
+ }
+ }
+ }
+ },
"inbox_create_payload": {
"type": "object",
"properties": {
@@ -4703,6 +4844,15 @@
"required": true,
"description": "The numeric ID of the message"
},
+ "scheduled_message_id": {
+ "in": "path",
+ "name": "scheduled_message_id",
+ "schema": {
+ "type": "integer"
+ },
+ "required": true,
+ "description": "The numeric ID of the scheduled message"
+ },
"page": {
"in": "query",
"name": "page",
@@ -4838,6 +4988,7 @@
"Inboxes",
"Integrations",
"Messages",
+ "Scheduled Messages",
"Profile",
"Reports",
"Teams",
diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json
index 2b81a67fd..90eea2fdd 100644
--- a/swagger/tag_groups/platform_swagger.json
+++ b/swagger/tag_groups/platform_swagger.json
@@ -1618,6 +1618,103 @@
}
}
},
+ "scheduled_message": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "ID of the scheduled message"
+ },
+ "content": {
+ "type": "string",
+ "description": "The text content of the scheduled message"
+ },
+ "inbox_id": {
+ "type": "integer",
+ "description": "ID of the inbox"
+ },
+ "conversation_id": {
+ "type": "integer",
+ "description": "Display ID of the conversation"
+ },
+ "account_id": {
+ "type": "integer",
+ "description": "ID of the account"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "draft",
+ "pending",
+ "sent",
+ "failed"
+ ],
+ "description": "The status of the scheduled message"
+ },
+ "scheduled_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the message is scheduled to be sent"
+ },
+ "template_params": {
+ "type": "object",
+ "description": "Template parameters for WhatsApp template messages"
+ },
+ "author_id": {
+ "type": "integer",
+ "description": "ID of the author who created the scheduled message"
+ },
+ "author_type": {
+ "type": "string",
+ "description": "Type of the author (User, Contact, etc.)"
+ },
+ "created_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the scheduled message was created"
+ },
+ "updated_at": {
+ "type": "integer",
+ "description": "Unix timestamp when the scheduled message was last updated"
+ },
+ "author": {
+ "type": "object",
+ "description": "The author object (User/Agent details)"
+ },
+ "attachment": {
+ "type": "object",
+ "description": "The file attachment object if any",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "ID of the attachment"
+ },
+ "scheduled_message_id": {
+ "type": "integer",
+ "description": "ID of the scheduled message this attachment belongs to"
+ },
+ "file_type": {
+ "type": "string",
+ "description": "MIME type of the file"
+ },
+ "account_id": {
+ "type": "integer",
+ "description": "ID of the account"
+ },
+ "file_url": {
+ "type": "string",
+ "description": "URL to access the file"
+ },
+ "blob_id": {
+ "type": "string",
+ "description": "Signed blob ID for the attachment"
+ },
+ "filename": {
+ "type": "string",
+ "description": "Name of the file"
+ }
+ }
+ }
+ }
+ },
"user": {
"type": "object",
"properties": {
@@ -3408,6 +3505,50 @@
}
}
},
+ "scheduled_message_create_update_payload": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "string",
+ "description": "The text content of the scheduled message",
+ "example": "Hello! This is a scheduled message."
+ },
+ "scheduled_at": {
+ "type": "string",
+ "format": "date-time",
+ "description": "ISO 8601 datetime when the message should be sent",
+ "example": "2026-02-01T10:00:00Z"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "draft",
+ "pending"
+ ],
+ "description": "The status of the scheduled message. Use 'draft' to save without scheduling, 'pending' to schedule the message.",
+ "example": "pending"
+ },
+ "attachment": {
+ "type": "string",
+ "format": "binary",
+ "description": "File attachment for the scheduled message"
+ },
+ "template_params": {
+ "type": "object",
+ "description": "Template parameters for WhatsApp template messages",
+ "example": {
+ "name": "order_confirmation",
+ "category": "MARKETING",
+ "language": "en",
+ "processed_params": {
+ "body": {
+ "1": "121212"
+ }
+ }
+ }
+ }
+ }
+ },
"inbox_create_payload": {
"type": "object",
"properties": {
@@ -5464,6 +5605,15 @@
"required": true,
"description": "The numeric ID of the message"
},
+ "scheduled_message_id": {
+ "in": "path",
+ "name": "scheduled_message_id",
+ "schema": {
+ "type": "integer"
+ },
+ "required": true,
+ "description": "The numeric ID of the scheduled message"
+ },
"page": {
"in": "query",
"name": "page",
@@ -5611,6 +5761,7 @@
"Inboxes",
"Integrations",
"Messages",
+ "Scheduled Messages",
"Profile",
"Reports",
"Teams",