## Code Context **Query:** automation rule, macro, canned response, automation actions, automation conditions, automation listener, automation execution ### Entry Points - **AutomationRuleListener** (class) - app/listeners/automation_rule_listener.rb:1 - **AutomationRule** (class) - app/models/automation_rule.rb:20 - **CannedResponse** (class) - app/javascript/dashboard/api/cannedResponse.js:5 ### Related Symbols - app/listeners/base_listener.rb: BaseListener:1 - app/models/application_record.rb: ApplicationRecord:1 - app/javascript/dashboard/api/ApiClient.js: ApiClient:5 - app/listeners/automation_rule_listener.rb: conversation_updated:2, conversation_created:6, conversation_opened:10, conversation_resolved:14, message_created:18, process_conversation_event:39, rule_present?:59 ### Code #### AutomationRuleListener (app/listeners/automation_rule_listener.rb:1) ```ruby class AutomationRuleListener < BaseListener def conversation_updated(event) process_conversation_event(event, 'conversation_updated') end def conversation_created(event) process_conversation_event(event, 'conversation_created') end def conversation_opened(event) process_conversation_event(event, 'conversation_opened') end def conversation_resolved(event) process_conversation_event(event, 'conversation_resolved') end def message_created(event) message = event.data[:message] return if ignore_message_created_event?(event) account = message.try(:account) changed_attributes = event.data[:changed_attributes] return unless rule_present?('message_created', account) rules = current_account_rules('message_created', account) rules.each do |rule| conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, message.conversation, { message: message, changed_attributes: changed_attributes }).perform ::AutomationRules::ActionService.new(rule, account, message.conversation).perform if conditions_match.present? end end private def process_conversation_event(event, event_name) return if performed_by_automation?(event) auto_reply_skip_events = %w[conversation_created conversation_opened] return if auto_reply_skip_events.include?(event_name) && ignore_auto_reply_event?(event) conversation = event.data[:c // ... truncated ... ``` #### AutomationRule (app/models/automation_rule.rb:20) ```ruby class AutomationRule < ApplicationRecord include Rails.application.routes.url_helpers include Reauthorizable belongs_to :account has_many_attached :files validate :json_conditions_format validate :json_actions_format validate :query_operator_presence validate :query_operator_value validates :account_id, presence: true after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? } scope :active, -> { where(active: true) } def conditions_attributes %w[content email country_code status message_type browser_language assignee_id team_id referer city company_name inbox_id mail_subject phone_number priority conversation_language labels private_note] end def actions_attributes %w[send_message add_label remove_label send_email_to_team assign_team assign_agent remove_assigned_agent remove_assigned_team send_webhook_event mute_conversation send_attachment change_status resolve_conversation open_conversation pending_conversation snooze_conversation change_priority send_email_transcript add_private_note].freeze end def file_base_data files.map do |file| { id: file.id, automation_rule_id: id, file_type: file.content_type, account_id: account_id, file_url: url_for(file), blob_id: file.blob_id, filename: file.filename.to_s } end end private def json_conditions_format return if conditions.blank? attributes = conditi // ... truncated ... ``` #### CannedResponse (app/javascript/dashboard/api/cannedResponse.js:5) ```javascript class CannedResponse extends ApiClient { constructor() { super('canned_responses', { accountScoped: true }); } get({ searchKey }) { const url = searchKey ? `${this.url}?search=${searchKey}` : this.url; return axios.get(url); } } ``` #### conversation_updated (app/listeners/automation_rule_listener.rb:2) ```ruby def conversation_updated(event) process_conversation_event(event, 'conversation_updated') end ``` #### conversation_created (app/listeners/automation_rule_listener.rb:6) ```ruby def conversation_created(event) process_conversation_event(event, 'conversation_created') end ``` #### conversation_opened (app/listeners/automation_rule_listener.rb:10) ```ruby def conversation_opened(event) process_conversation_event(event, 'conversation_opened') end ``` #### conversation_resolved (app/listeners/automation_rule_listener.rb:14) ```ruby def conversation_resolved(event) process_conversation_event(event, 'conversation_resolved') end ``` #### message_created (app/listeners/automation_rule_listener.rb:18) ```ruby def message_created(event) message = event.data[:message] return if ignore_message_created_event?(event) account = message.try(:account) changed_attributes = event.data[:changed_attributes] return unless rule_present?('message_created', account) rules = current_account_rules('message_created', account) rules.each do |rule| conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, message.conversation, { message: message, changed_attributes: changed_attributes }).perform ::AutomationRules::ActionService.new(rule, account, message.conversation).perform if conditions_match.present? end end ``` #### process_conversation_event (app/listeners/automation_rule_listener.rb:39) ```ruby def process_conversation_event(event, event_name) return if performed_by_automation?(event) auto_reply_skip_events = %w[conversation_created conversation_opened] return if auto_reply_skip_events.include?(event_name) && ignore_auto_reply_event?(event) conversation = event.data[:conversation] account = conversation.account changed_attributes = event.data[:changed_attributes] return unless rule_present?(event_name, account) rules = current_account_rules(event_name, account) rules.each do |rule| conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present? end end ``` #### rule_present? (app/listeners/automation_rule_listener.rb:59) ```ruby def rule_present?(event_name, account) return if account.blank? current_account_rules(event_name, account).any? end ``` #### current_account_rules (app/listeners/automation_rule_listener.rb:65) ```ruby def current_account_rules(event_name, account) AutomationRule.where( event_name: event_name, account_id: account.id, active: true ) end ``` #### performed_by_automation? (app/listeners/automation_rule_listener.rb:73) ```ruby def performed_by_automation?(event) event.data[:performed_by].present? && event.data[:performed_by].instance_of?(AutomationRule) end ``` #### ignore_auto_reply_event? (app/listeners/automation_rule_listener.rb:77) ```ruby def ignore_auto_reply_event?(event) conversation = event.data[:conversation] conversation.additional_attributes['auto_reply'].present? end ``` #### ignore_message_created_event? (app/listeners/automation_rule_listener.rb:82) ```ruby def ignore_message_created_event?(event) message = event.data[:message] performed_by_automation?(event) || message.activity? || message.auto_reply_email? end ``` #### listeners (app/dispatchers/async_dispatcher.rb:11) ```ruby def listeners [ AutomationRuleListener.instance, CampaignListener.instance, CsatSurveyListener.instance, HookListener.instance, InstallationWebhookListener.instance, NotificationListener.instance, ParticipationListener.instance, Conversations::UnreadCounts::Listener.instance, ReportingEventListener.instance, WebhookListener.instance ] end ``` #### conditions_attributes (app/models/automation_rule.rb:37) ```ruby def conditions_attributes %w[content email country_code status message_type browser_language assignee_id team_id referer city company_name inbox_id mail_subject phone_number priority conversation_language labels private_note] end ``` #### actions_attributes (app/models/automation_rule.rb:42) ```ruby def actions_attributes %w[send_message add_label remove_label send_email_to_team assign_team assign_agent remove_assigned_agent remove_assigned_team send_webhook_event mute_conversation send_attachment change_status resolve_conversation open_conversation pending_conversation snooze_conversation change_priority send_email_transcript add_private_note].freeze end ``` #### file_base_data (app/models/automation_rule.rb:49) ```ruby def file_base_data files.map do |file| { id: file.id, automation_rule_id: id, file_type: file.content_type, account_id: account_id, file_url: url_for(file), blob_id: file.blob_id, filename: file.filename.to_s } end end ``` #### json_conditions_format (app/models/automation_rule.rb:65) ```ruby def json_conditions_format return if conditions.blank? attributes = conditions.map { |obj, _| obj['attribute_key'] } conditions = attributes - conditions_attributes conditions -= account.custom_attribute_definitions.pluck(:attribute_key) errors.add(:conditions, "Automation conditions #{conditions.join(',')} not supported.") if conditions.any? end ``` #### json_actions_format (app/models/automation_rule.rb:74) ```ruby def json_actions_format return if actions.blank? attributes = actions.map { |obj, _| obj['action_name'] } actions = attributes - actions_attributes errors.add(:actions, "Automation actions #{actions.join(',')} not supported.") if actions.any? end ``` #### query_operator_presence (app/models/automation_rule.rb:83) ```ruby def query_operator_presence return if conditions.blank? operators = conditions.select { |obj, _| obj['query_operator'].nil? } errors.add(:conditions, 'Automation conditions should have query operator.') if operators.length > 1 end ``` #### query_operator_value (app/models/automation_rule.rb:92) ```ruby def query_operator_value conditions.each do |obj| validate_single_condition(obj) end end ``` #### validate_single_condition (app/models/automation_rule.rb:98) ```ruby def validate_single_condition(condition) query_operator = condition['query_operator'] return if query_operator.nil? return if query_operator.empty? operator = query_operator.upcase errors.add(:conditions, 'Query operator must be either "AND" or "OR"') unless %w[AND OR].include?(operator) end ``` #### migrate_automation_rule_conditions (db/migrate/20260427094500_rename_company_condition_key_in_automation_rules.rb:11) ```ruby def migrate_automation_rule_conditions AutomationRule.find_each do |rule| conditions = rename_company_attribute_key(rule.conditions) next if conditions == rule.conditions rule.update_column(:conditions, conditions) # rubocop:disable Rails/SkipsModelValidations end end ``` #### constructor (app/javascript/dashboard/api/cannedResponse.js:6) ```javascript constructor() { super('canned_responses', { accountScoped: true }); } ``` #### get (app/javascript/dashboard/api/cannedResponse.js:10) ```javascript get({ searchKey }) { const url = searchKey ? `${this.url}?search=${searchKey}` : this.url; return axios.get(url); } ``` #### BaseListener (app/listeners/base_listener.rb:1) ```ruby class BaseListener include Singleton def extract_conversation_and_account(event) conversation = event.data[:conversation] [conversation, conversation.account] end def extract_notification_and_account(event) notification = event.data[:notification] notification_finder = NotificationFinder.new(notification.user, notification.account) unread_count = notification_finder.unread_count count = notification_finder.count [notification, notification.account, unread_count, count] end def extract_message_and_account(event) message = event.data[:message] [message, message.account] end def extract_contact_and_account(event) contact = event.data[:contact] [contact, contact.account] end def extract_inbox_and_account(event) inbox = event.data[:inbox] [inbox, inbox.account] end def extract_changed_attributes(event) changed_attributes = event.data[:changed_attributes] return if changed_attributes.blank? changed_attributes.map { |k, v| { k => { previous_value: v[0], current_value: v[1] } } } end end ``` #### ApplicationRecord (app/models/application_record.rb:1) ```ruby class ApplicationRecord < ActiveRecord::Base include Events::Types self.abstract_class = true before_validation :validates_column_content_length # the models that exposed in email templates through liquid def droppables %w[Account Channel Conversation Inbox User Message] end # ModelDrop class should exist in app/drops def to_drop return unless droppables.include?(self.class.name) "#{self.class.name}Drop".constantize.new(self) end private # Generic validation for all columns of type string and text # Validates the length of the column to prevent DOS via large payloads # if a custom length validation is already present, skip the validation def validates_column_content_length self.class.columns.each do |column| check_and_validate_content_length(column) if column_of_type_string_or_text?(column) end end def column_of_type_string_or_text?(column) %i[string text].include?(column.type) end def check_and_validate_content_length(column) length_validator = self.class.validators_on(column.name).find { |v| v.kind == :length } validate_content_length(column) if length_validator.blank? end def validate_content_length(column) max_length = column.type == :text ? 20_000 : 255 return if self[column.name].nil? || self[column.name].length <= max_length errors.add(column.name.to_sym, "is too long (maximum is #{max_length} characters)") end def normalize_empty_string_to_nil(attrs = []) attrs // ... truncated ... ``` #### ApiClient (app/javascript/dashboard/api/ApiClient.js:5) ```javascript class ApiClient { constructor(resource, options = {}) { this.apiVersion = `/api/${options.apiVersion || DEFAULT_API_VERSION}`; this.options = options; this.resource = resource; } get url() { return `${this.baseUrl()}/${this.resource}`; } // eslint-disable-next-line class-methods-use-this get accountIdFromRoute() { const isInsideAccountScopedURLs = window.location.pathname.includes('/app/accounts'); if (isInsideAccountScopedURLs) { return window.location.pathname.split('/')[3]; } return ''; } baseUrl() { let url = this.apiVersion; if (this.options.enterprise) { url = `/enterprise${url}`; } if (this.options.accountScoped && this.accountIdFromRoute) { url = `${url}/accounts/${this.accountIdFromRoute}`; } return url; } get() { return axios.get(this.url); } show(id) { return axios.get(`${this.url}/${id}`); } create(data) { return axios.post(this.url, data); } update(id, data) { return axios.patch(`${this.url}/${id}`, data); } delete(id) { return axios.delete(`${this.url}/${id}`); } } ```