discourse/plugins/automation/app/models/discourse_automation/automation.rb
Osama Sayegh 0e44072b2b
FIX: Prevent infinite loop of automations triggering each other (#26814)
It's currently possible to setup multiple automation rules that trigger each other resulting in an infinite loop. To prevent that, this commit adds a global "circuit breaker" that prevents all automations from triggering while an automation rule is executing.

Internal topic: t/124365.
2024-04-30 20:13:29 +03:00

192 lines
5.3 KiB
Ruby

# frozen_string_literal: true
module DiscourseAutomation
class Automation < ActiveRecord::Base
self.table_name = "discourse_automation_automations"
has_many :fields,
class_name: "DiscourseAutomation::Field",
dependent: :delete_all,
foreign_key: "automation_id"
has_many :pending_automations,
class_name: "DiscourseAutomation::PendingAutomation",
dependent: :delete_all,
foreign_key: "automation_id"
has_many :pending_pms,
class_name: "DiscourseAutomation::PendingPm",
dependent: :delete_all,
foreign_key: "automation_id"
validates :script, presence: true
validate :validate_trigger_fields
after_destroy do |automation|
UserCustomField.where(name: automation.new_user_custom_field_name).destroy_all
end
attr_accessor :running_in_background
def running_in_background!
@running_in_background = true
end
MIN_NAME_LENGTH = 5
MAX_NAME_LENGTH = 30
validates :name, length: { in: MIN_NAME_LENGTH..MAX_NAME_LENGTH }
def attach_custom_field(target)
if ![Topic, Post, User].any? { |m| target.is_a?(m) }
raise "Expected an instance of Topic/Post/User."
end
now = Time.now
fk = target.custom_fields_fk
row = {
fk => target.id,
:name => DiscourseAutomation::CUSTOM_FIELD,
:value => id,
:created_at => now,
:updated_at => now,
}
relation = "#{target.class.name}CustomField".constantize
relation.upsert(
row,
unique_by:
"idx_#{target.class.name.downcase}_custom_fields_discourse_automation_unique_id_partial",
)
end
def detach_custom_field(target)
if ![Topic, Post, User].any? { |m| target.is_a?(m) }
raise "Expected an instance of Topic/Post/User."
end
fk = target.custom_fields_fk
relation = "#{target.class.name}CustomField".constantize
relation.where(
fk => target.id,
:name => DiscourseAutomation::CUSTOM_FIELD,
:value => id,
).delete_all
end
def trigger_field(name)
field = fields.find_by(target: "trigger", name: name)
field ? field.metadata : {}
end
def has_trigger_field?(name)
!!fields.find_by(target: "trigger", name: name)
end
def script_field(name)
field = fields.find_by(target: "script", name: name)
field ? field.metadata : {}
end
def upsert_field!(name, component, metadata, target: "script")
field = fields.find_or_initialize_by(name: name, component: component, target: target)
field.update!(metadata: metadata)
end
def self.deserialize_context(context)
new_context = ActiveSupport::HashWithIndifferentAccess.new
context.each do |key, value|
if key.start_with?("_serialized_")
new_key = key[12..-1]
found = nil
if value["class"] == "Symbol"
found = value["value"].to_sym
else
found = value["class"].constantize.find_by(id: value["id"])
end
new_context[new_key] = found
else
new_context[key] = value
end
end
new_context
end
def self.serialize_context(context)
new_context = {}
context.each do |k, v|
if v.is_a?(Symbol)
new_context["_serialized_#{k}"] = { "class" => "Symbol", "value" => v.to_s }
elsif v.is_a?(ActiveRecord::Base)
new_context["_serialized_#{k}"] = { "class" => v.class.name, "id" => v.id }
else
new_context[k] = v
end
end
new_context
end
def trigger_in_background!(context = {})
Jobs.enqueue(
:discourse_automation_trigger,
automation_id: id,
context: self.class.serialize_context(context),
)
end
def trigger!(context = {})
if enabled
if active_id = DiscourseAutomation.get_active_automation
Rails.logger.warn(<<~TEXT.strip)
[automation] potential automations infinite loop detected: skipping automation #{self.id} because automation #{active_id} is still executing.")
TEXT
return
end
begin
DiscourseAutomation.set_active_automation(self.id)
if scriptable.background && !running_in_background
trigger_in_background!(context)
else
triggerable&.on_call&.call(self, serialized_fields)
scriptable.script.call(context, serialized_fields, self)
end
ensure
DiscourseAutomation.set_active_automation(nil)
end
end
end
def triggerable
trigger && @triggerable ||= DiscourseAutomation::Triggerable.new(trigger, self)
end
def scriptable
script && @scriptable ||= DiscourseAutomation::Scriptable.new(script, self)
end
def serialized_fields
fields
&.pluck(:name, :metadata)
&.reduce({}) do |acc, hash|
name, field = hash
acc[name] = field
acc
end || {}
end
def reset!
pending_pms.delete_all
scriptable&.on_reset&.call(self)
end
def new_user_custom_field_name
"automation_#{self.id}_new_user"
end
private
def validate_trigger_fields
!triggerable || triggerable.valid?(self)
end
end
end