mirror of
https://github.com/discourse/discourse.git
synced 2025-05-22 07:53:49 +08:00
FEATURE: Merge discourse-automation (#26432)
Automation (previously known as discourse-automation) is now a core plugin.
This commit is contained in:
21
plugins/automation/lib/discourse_automation/engine.rb
Normal file
21
plugins/automation/lib/discourse_automation/engine.rb
Normal file
@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ::DiscourseAutomation
|
||||
class Engine < ::Rails::Engine
|
||||
engine_name PLUGIN_NAME
|
||||
isolate_namespace DiscourseAutomation
|
||||
end
|
||||
|
||||
def self.filter_by_trigger(items, trigger)
|
||||
trigger = trigger&.to_sym
|
||||
|
||||
indexed_items =
|
||||
items.each_with_object({}) do |item, acc|
|
||||
if item[:triggerable] == trigger || item[:triggerable].nil?
|
||||
acc[item[:name]] = item if acc[item[:name]].nil? || item[:triggerable] == trigger
|
||||
end
|
||||
end
|
||||
|
||||
indexed_items.values
|
||||
end
|
||||
end
|
278
plugins/automation/lib/discourse_automation/event_handlers.rb
Normal file
278
plugins/automation/lib/discourse_automation/event_handlers.rb
Normal file
@ -0,0 +1,278 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAutomation
|
||||
module EventHandlers
|
||||
def self.handle_post_created_edited(post, action)
|
||||
return if post.post_type != Post.types[:regular] || post.user_id < 0
|
||||
topic = post.topic
|
||||
return if topic.blank?
|
||||
|
||||
name = DiscourseAutomation::Triggers::POST_CREATED_EDITED
|
||||
|
||||
DiscourseAutomation::Automation
|
||||
.where(trigger: name, enabled: true)
|
||||
.find_each do |automation|
|
||||
first_post_only = automation.trigger_field("first_post_only")
|
||||
if first_post_only["value"]
|
||||
next if post.user.user_stat.post_count != 1
|
||||
end
|
||||
|
||||
first_topic_only = automation.trigger_field("first_topic_only")
|
||||
if first_topic_only["value"]
|
||||
next if post.post_number != 1
|
||||
next if post.user.user_stat.topic_count != 1
|
||||
end
|
||||
|
||||
valid_trust_levels = automation.trigger_field("valid_trust_levels")
|
||||
if valid_trust_levels["value"]
|
||||
next unless valid_trust_levels["value"].include?(post.user.trust_level)
|
||||
end
|
||||
|
||||
restricted_category = automation.trigger_field("restricted_category")
|
||||
if restricted_category["value"]
|
||||
category_ids =
|
||||
if topic.category_id.blank?
|
||||
[]
|
||||
else
|
||||
[topic.category_id, topic.category.parent_category_id]
|
||||
end
|
||||
next if !category_ids.include?(restricted_category["value"])
|
||||
end
|
||||
|
||||
restricted_group_id = automation.trigger_field("restricted_group")["value"]
|
||||
if restricted_group_id.present?
|
||||
next if !topic.private_message?
|
||||
|
||||
target_group_ids = topic.allowed_groups.pluck(:id)
|
||||
next if restricted_group_id != target_group_ids.first
|
||||
|
||||
ignore_group_members = automation.trigger_field("ignore_group_members")
|
||||
next if ignore_group_members["value"] && post.user.in_any_groups?([restricted_group_id])
|
||||
end
|
||||
|
||||
ignore_automated = automation.trigger_field("ignore_automated")
|
||||
next if ignore_automated["value"] && post.incoming_email&.is_auto_generated?
|
||||
|
||||
action_type = automation.trigger_field("action_type")
|
||||
selected_action = action_type["value"]&.to_sym
|
||||
|
||||
if selected_action
|
||||
next if selected_action == :created && action != :create
|
||||
next if selected_action == :edited && action != :edit
|
||||
end
|
||||
|
||||
automation.trigger!("kind" => name, "action" => action, "post" => post)
|
||||
end
|
||||
end
|
||||
|
||||
def self.handle_user_updated(user)
|
||||
return if user.id < 0
|
||||
|
||||
name = DiscourseAutomation::Triggers::USER_UPDATED
|
||||
|
||||
DiscourseAutomation::Automation
|
||||
.where(trigger: name, enabled: true)
|
||||
.find_each do |automation|
|
||||
once_per_user = automation.trigger_field("once_per_user")["value"]
|
||||
if once_per_user &&
|
||||
UserCustomField.exists?(name: DiscourseAutomation::CUSTOM_FIELD, user_id: user.id)
|
||||
next
|
||||
end
|
||||
|
||||
required_custom_fields = automation.trigger_field("custom_fields")
|
||||
user_data = {}
|
||||
user_custom_fields_data = DB.query <<-SQL
|
||||
SELECT uf.name AS field_name, ucf.value AS field_value
|
||||
FROM user_fields uf
|
||||
JOIN user_custom_fields ucf ON CONCAT('user_field_', uf.id) = ucf.name
|
||||
WHERE ucf.user_id = #{user.id};
|
||||
SQL
|
||||
user_custom_fields_data =
|
||||
user_custom_fields_data.each_with_object({}) do |obj, hash|
|
||||
field_name = obj.field_name
|
||||
field_value = obj.field_value
|
||||
hash[field_name] = field_value
|
||||
end
|
||||
if required_custom_fields["value"]
|
||||
if required_custom_fields["value"].any? { |field|
|
||||
user_custom_fields_data[field].blank?
|
||||
}
|
||||
next
|
||||
end
|
||||
user_data[:custom_fields] = user_custom_fields_data
|
||||
end
|
||||
|
||||
required_user_profile_fields = automation.trigger_field("user_profile")
|
||||
user_profile_data = UserProfile.find(user.id).attributes
|
||||
if required_user_profile_fields["value"]
|
||||
if required_user_profile_fields["value"].any? { |field|
|
||||
user_profile_data[field].blank?
|
||||
}
|
||||
next
|
||||
end
|
||||
user_data[:profile_data] = user_profile_data
|
||||
end
|
||||
|
||||
automation.attach_custom_field(user)
|
||||
automation.trigger!("kind" => name, "user" => user, "user_data" => user_data)
|
||||
end
|
||||
end
|
||||
|
||||
def self.handle_category_created_edited(category, action)
|
||||
name = DiscourseAutomation::Triggers::CATEGORY_CREATED_EDITED
|
||||
|
||||
DiscourseAutomation::Automation
|
||||
.where(trigger: name, enabled: true)
|
||||
.find_each do |automation|
|
||||
restricted_category = automation.trigger_field("restricted_category")
|
||||
if restricted_category["value"].present?
|
||||
next if restricted_category["value"] != category.parent_category_id
|
||||
end
|
||||
|
||||
automation.trigger!("kind" => name, "action" => action, "category" => category)
|
||||
end
|
||||
end
|
||||
|
||||
def self.handle_pm_created(topic)
|
||||
return if topic.user_id < 0
|
||||
|
||||
user = topic.user
|
||||
target_usernames = topic.allowed_users.pluck(:username) - [user.username]
|
||||
target_group_ids = topic.allowed_groups.pluck(:id)
|
||||
return if (target_usernames.length + target_group_ids.length) > 1
|
||||
|
||||
name = DiscourseAutomation::Triggers::PM_CREATED
|
||||
|
||||
DiscourseAutomation::Automation
|
||||
.where(trigger: name, enabled: true)
|
||||
.find_each do |automation|
|
||||
restricted_username = automation.trigger_field("restricted_user")["value"]
|
||||
next if restricted_username.present? && restricted_username != target_usernames.first
|
||||
|
||||
restricted_group_id = automation.trigger_field("restricted_group")["value"]
|
||||
next if restricted_group_id.present? && restricted_group_id != target_group_ids.first
|
||||
|
||||
ignore_staff = automation.trigger_field("ignore_staff")
|
||||
next if ignore_staff["value"] && user.staff?
|
||||
|
||||
ignore_group_members = automation.trigger_field("ignore_group_members")
|
||||
next if ignore_group_members["value"] && user.in_any_groups?([restricted_group_id])
|
||||
|
||||
ignore_automated = automation.trigger_field("ignore_automated")
|
||||
next if ignore_automated["value"] && topic.first_post.incoming_email&.is_auto_generated?
|
||||
|
||||
valid_trust_levels = automation.trigger_field("valid_trust_levels")
|
||||
if valid_trust_levels["value"]
|
||||
next if !valid_trust_levels["value"].include?(user.trust_level)
|
||||
end
|
||||
|
||||
automation.trigger!("kind" => name, "post" => topic.first_post)
|
||||
end
|
||||
end
|
||||
|
||||
def self.handle_after_post_cook(post, cooked)
|
||||
return cooked if post.post_type != Post.types[:regular] || post.post_number > 1
|
||||
|
||||
name = DiscourseAutomation::Triggers::AFTER_POST_COOK
|
||||
|
||||
DiscourseAutomation::Automation
|
||||
.where(trigger: name, enabled: true)
|
||||
.find_each do |automation|
|
||||
valid_trust_levels = automation.trigger_field("valid_trust_levels")
|
||||
if valid_trust_levels["value"]
|
||||
next unless valid_trust_levels["value"].include?(post.user.trust_level)
|
||||
end
|
||||
|
||||
restricted_category = automation.trigger_field("restricted_category")
|
||||
if restricted_category["value"]
|
||||
category_ids = [post.topic&.category&.parent_category&.id, post.topic&.category&.id]
|
||||
next if !category_ids.compact.include?(restricted_category["value"])
|
||||
end
|
||||
|
||||
restricted_tags = automation.trigger_field("restricted_tags")
|
||||
if tag_names = restricted_tags["value"]
|
||||
found = false
|
||||
next if !post.topic
|
||||
|
||||
post.topic.tags.each do |tag|
|
||||
found ||= tag_names.include?(tag.name)
|
||||
break if found
|
||||
end
|
||||
|
||||
next if !found
|
||||
end
|
||||
|
||||
if new_cooked = automation.trigger!("kind" => name, "post" => post, "cooked" => cooked)
|
||||
cooked = new_cooked
|
||||
end
|
||||
end
|
||||
|
||||
cooked
|
||||
end
|
||||
|
||||
def self.handle_user_promoted(user_id, new_trust_level, old_trust_level)
|
||||
trigger = DiscourseAutomation::Triggers::USER_PROMOTED
|
||||
user = User.find_by(id: user_id)
|
||||
return if user.blank?
|
||||
|
||||
# don't want to do anything if the user is demoted. this should probably
|
||||
# be a separate event in core
|
||||
return if new_trust_level < old_trust_level
|
||||
|
||||
DiscourseAutomation::Automation
|
||||
.where(trigger: trigger, enabled: true)
|
||||
.find_each do |automation|
|
||||
trust_level_code_all = DiscourseAutomation::USER_PROMOTED_TRUST_LEVEL_CHOICES.first[:id]
|
||||
|
||||
restricted_group_id = automation.trigger_field("restricted_group")["value"]
|
||||
trust_level_transition = automation.trigger_field("trust_level_transition")["value"]
|
||||
trust_level_transition = trust_level_transition || trust_level_code_all
|
||||
|
||||
if restricted_group_id.present? &&
|
||||
!GroupUser.exists?(user_id: user_id, group_id: restricted_group_id)
|
||||
next
|
||||
end
|
||||
|
||||
transition_code = "TL#{old_trust_level}#{new_trust_level}"
|
||||
if trust_level_transition == trust_level_code_all ||
|
||||
trust_level_transition == transition_code
|
||||
automation.trigger!(
|
||||
"kind" => trigger,
|
||||
"usernames" => [user.username],
|
||||
"placeholders" => {
|
||||
"trust_level_transition" =>
|
||||
I18n.t(
|
||||
"discourse_automation.triggerables.user_promoted.transition_placeholder",
|
||||
from_level_name: TrustLevel.name(old_trust_level),
|
||||
to_level_name: TrustLevel.name(new_trust_level),
|
||||
),
|
||||
},
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.handle_stalled_topic(post)
|
||||
return if post.topic.blank?
|
||||
return if post.user_id != post.topic.user_id
|
||||
|
||||
DiscourseAutomation::Automation
|
||||
.where(trigger: DiscourseAutomation::Triggers::STALLED_TOPIC)
|
||||
.where(enabled: true)
|
||||
.find_each do |automation|
|
||||
fields = automation.serialized_fields
|
||||
|
||||
categories = fields.dig("categories", "value")
|
||||
next if categories && !categories.include?(post.topic.category_id)
|
||||
|
||||
tags = fields.dig("tags", "value")
|
||||
next if tags && (tags & post.topic.tags.map(&:name)).empty?
|
||||
|
||||
DiscourseAutomation::UserGlobalNotice
|
||||
.where(identifier: automation.id)
|
||||
.where(user_id: post.user_id)
|
||||
.destroy_all
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAutomation
|
||||
module PostExtension
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
prepended { validate :discourse_automation_topic_required_words }
|
||||
|
||||
def discourse_automation_topic_required_words
|
||||
return if !SiteSetting.discourse_automation_enabled
|
||||
return if self.post_type == Post.types[:small_action]
|
||||
return if !topic
|
||||
return if topic.custom_fields[DiscourseAutomation::CUSTOM_FIELD].blank?
|
||||
|
||||
topic.custom_fields[DiscourseAutomation::CUSTOM_FIELD].each do |automation_id|
|
||||
automation = DiscourseAutomation::Automation.find_by(id: automation_id)
|
||||
next if automation&.script != DiscourseAutomation::Scripts::TOPIC_REQUIRED_WORDS
|
||||
|
||||
words = automation.fields.find_by(name: "words")&.metadata&.[]("value")
|
||||
next if words.blank?
|
||||
|
||||
if words.none? { |word| raw.include?(word) }
|
||||
errors.add(
|
||||
:base,
|
||||
I18n.t(
|
||||
"discourse_automation.scriptables.topic_required_words.errors.must_include_word",
|
||||
words: words.join(", "),
|
||||
),
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
303
plugins/automation/lib/discourse_automation/scriptable.rb
Normal file
303
plugins/automation/lib/discourse_automation/scriptable.rb
Normal file
@ -0,0 +1,303 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAutomation
|
||||
class Scriptable
|
||||
attr_reader :fields,
|
||||
:name,
|
||||
:not_found,
|
||||
:forced_triggerable,
|
||||
:background,
|
||||
:automation,
|
||||
:placeholders
|
||||
|
||||
@@plugin_triggerables ||= {}
|
||||
|
||||
class << self
|
||||
def add_plugin_triggerable(triggerable, scriptable)
|
||||
@@plugin_triggerables[scriptable.to_sym] ||= []
|
||||
@@plugin_triggerables[scriptable.to_sym] << triggerable.to_sym
|
||||
end
|
||||
|
||||
def plugin_triggerables
|
||||
@@plugin_triggerables
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(name, automation = nil)
|
||||
@name = name
|
||||
@version = 0
|
||||
@fields = []
|
||||
@placeholders = []
|
||||
@triggerables = (@@plugin_triggerables[name&.to_sym] || [])
|
||||
@script = proc {}
|
||||
@on_reset = proc {}
|
||||
@not_found = false
|
||||
@forced_triggerable = nil
|
||||
@background = false
|
||||
@automation = automation
|
||||
|
||||
eval! if @name
|
||||
end
|
||||
|
||||
def run_in_background
|
||||
@background = true
|
||||
end
|
||||
|
||||
def id
|
||||
"script"
|
||||
end
|
||||
|
||||
def scriptable?
|
||||
true
|
||||
end
|
||||
|
||||
def triggerable?
|
||||
false
|
||||
end
|
||||
|
||||
def eval!
|
||||
begin
|
||||
public_send("__scriptable_#{name.underscore}")
|
||||
rescue NoMethodError
|
||||
@not_found = true
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def triggerable!(*args)
|
||||
if args.present?
|
||||
@forced_triggerable = { triggerable: args[0], state: args[1] }
|
||||
else
|
||||
@forced_triggerable
|
||||
end
|
||||
end
|
||||
|
||||
def placeholder(name = nil, triggerable: nil, &block)
|
||||
if block_given?
|
||||
result = yield(@automation.serialized_fields, @automation)
|
||||
Array(result).each do |name|
|
||||
@placeholders << { name: name.to_sym, triggerable: triggerable&.to_sym }
|
||||
end
|
||||
elsif name
|
||||
@placeholders << { name: name.to_sym, triggerable: triggerable&.to_sym }
|
||||
end
|
||||
end
|
||||
|
||||
def version(*args)
|
||||
if args.present?
|
||||
@version, = args
|
||||
else
|
||||
@version
|
||||
end
|
||||
end
|
||||
|
||||
def permits_trigger?(triggerable)
|
||||
Array(triggerables.map(&:to_s)).include?(triggerable.to_s)
|
||||
end
|
||||
|
||||
def triggerables(*args)
|
||||
if args.present?
|
||||
@triggerables.push(*args[0])
|
||||
else
|
||||
forced_triggerable ? [forced_triggerable[:triggerable]] : @triggerables
|
||||
end
|
||||
end
|
||||
|
||||
def script(&block)
|
||||
if block_given?
|
||||
@script = block
|
||||
else
|
||||
@script
|
||||
end
|
||||
end
|
||||
|
||||
def on_reset(&block)
|
||||
if block_given?
|
||||
@on_reset_block = block
|
||||
else
|
||||
@on_reset_block
|
||||
end
|
||||
end
|
||||
|
||||
def field(name, component:, **options)
|
||||
@fields << {
|
||||
name: name,
|
||||
component: component,
|
||||
extra: {
|
||||
},
|
||||
accepts_placeholders: false,
|
||||
accepted_contexts: [],
|
||||
triggerable: nil,
|
||||
required: false,
|
||||
}.merge(options || {})
|
||||
end
|
||||
|
||||
def components
|
||||
fields.map { |f| f[:component] }.uniq
|
||||
end
|
||||
|
||||
def utils
|
||||
Utils
|
||||
end
|
||||
|
||||
module Utils
|
||||
def self.fetch_report(name, args = {})
|
||||
report = Report.find(name, args)
|
||||
|
||||
return if !report
|
||||
|
||||
return if !report.modes.include?(:table)
|
||||
|
||||
ordered_columns = report.labels.map { |l| l[:property] }
|
||||
|
||||
table = +"\n"
|
||||
table << "|" + report.labels.map { |l| l[:title] }.join("|") + "|\n"
|
||||
table << "|" + report.labels.count.times.map { "-" }.join("|") + "|\n"
|
||||
if report.data.count > 0
|
||||
report.data.each do |data|
|
||||
table << "|#{ordered_columns.map { |col| data[col] }.join("|")}|\n"
|
||||
end
|
||||
else
|
||||
table << "|" + report.labels.count.times.map { " " }.join("|") + "|\n"
|
||||
end
|
||||
table
|
||||
end
|
||||
|
||||
def self.apply_placeholders(input, map = {})
|
||||
input = input.dup
|
||||
map[:site_title] = SiteSetting.title
|
||||
|
||||
input = apply_report_placeholder(input)
|
||||
|
||||
map.each { |key, value| input = input.gsub("%%#{key.upcase}%%", value.to_s) }
|
||||
|
||||
input = Mustache.render(input, map).to_s
|
||||
end
|
||||
|
||||
REPORT_REGEX = /%%REPORT=(.*?)%%/
|
||||
def self.apply_report_placeholder(input = "")
|
||||
input.gsub(REPORT_REGEX) do |pattern|
|
||||
match = pattern.match(REPORT_REGEX)
|
||||
if match
|
||||
params = match[1].match(/^(.*?)(?:\s(.*))?$/)
|
||||
|
||||
args = { filters: {} }
|
||||
if params[2]
|
||||
params[2]
|
||||
.split(" ")
|
||||
.each do |param|
|
||||
key, value = param.split("=")
|
||||
if %w[start_date end_date].include?(key)
|
||||
args[key.to_sym] = begin
|
||||
Date.parse(value)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
else
|
||||
args[:filters][key.to_sym] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
fetch_report(params[1].downcase, args) || ""
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.send_pm(
|
||||
pm,
|
||||
sender: Discourse.system_user.username,
|
||||
delay: nil,
|
||||
automation_id: nil,
|
||||
prefers_encrypt: true
|
||||
)
|
||||
pm = pm.symbolize_keys
|
||||
prefers_encrypt = prefers_encrypt && !!defined?(EncryptedPostCreator)
|
||||
|
||||
if delay && delay.to_i > 0 && automation_id
|
||||
pm[:execute_at] = delay.to_i.minutes.from_now
|
||||
pm[:sender] = sender
|
||||
pm[:automation_id] = automation_id
|
||||
pm[:prefers_encrypt] = prefers_encrypt
|
||||
DiscourseAutomation::PendingPm.create!(pm)
|
||||
else
|
||||
sender = User.find_by(username: sender)
|
||||
if !sender
|
||||
Rails.logger.warn "[discourse-automation] Did not send PM #{pm[:title]} - sender does not exist: `#{sender}`"
|
||||
return
|
||||
end
|
||||
|
||||
pm[:target_usernames] = Array.wrap(pm[:target_usernames])
|
||||
pm[:target_group_names] = Array.wrap(pm[:target_group_names])
|
||||
pm[:target_emails] = Array.wrap(pm[:target_emails])
|
||||
|
||||
if pm[:target_usernames].empty? && pm[:target_group_names].empty? &&
|
||||
pm[:target_emails].empty?
|
||||
Rails.logger.warn "[discourse-automation] Did not send PM - no target usernames, groups or emails"
|
||||
return
|
||||
end
|
||||
|
||||
non_existing_targets = []
|
||||
|
||||
if pm[:target_usernames].present?
|
||||
existing_target_usernames = User.where(username: pm[:target_usernames]).pluck(:username)
|
||||
if existing_target_usernames.length != pm[:target_usernames].length
|
||||
non_existing_targets += pm[:target_usernames] - existing_target_usernames
|
||||
pm[:target_usernames] = existing_target_usernames
|
||||
end
|
||||
end
|
||||
|
||||
if pm[:target_group_names].present?
|
||||
existing_target_groups = Group.where(name: pm[:target_group_names]).pluck(:name)
|
||||
if existing_target_groups.length != pm[:target_group_names].length
|
||||
non_existing_targets += pm[:target_group_names] - existing_target_groups
|
||||
pm[:target_group_names] = existing_target_groups
|
||||
end
|
||||
end
|
||||
|
||||
if pm[:target_emails].present?
|
||||
valid_emails = pm[:target_emails].select { |email| Email.is_valid?(email) }
|
||||
if valid_emails.length != pm[:target_emails].length
|
||||
non_existing_targets += pm[:target_emails] - valid_emails
|
||||
pm[:target_emails] = valid_emails
|
||||
end
|
||||
end
|
||||
|
||||
post_created = false
|
||||
pm = pm.merge(archetype: Archetype.private_message)
|
||||
pm[:target_usernames] = pm[:target_usernames].join(",")
|
||||
pm[:target_group_names] = pm[:target_group_names].join(",")
|
||||
pm[:target_emails] = pm[:target_emails].join(",")
|
||||
|
||||
if pm[:target_usernames].blank? && pm[:target_group_names].blank? &&
|
||||
pm[:target_emails].blank?
|
||||
Rails.logger.warn "[discourse-automation] Did not send PM #{pm[:title]} - no valid targets exist"
|
||||
return
|
||||
elsif non_existing_targets.any?
|
||||
Rails.logger.warn "[discourse-automation] Did not send PM #{pm[:title]} to all users - some do not exist: `#{non_existing_targets.join(",")}`"
|
||||
end
|
||||
|
||||
post_created = EncryptedPostCreator.new(sender, pm).create if prefers_encrypt
|
||||
|
||||
PostCreator.new(sender, pm).create! if !post_created
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.add(identifier, &block)
|
||||
@all_scriptables = nil
|
||||
define_method("__scriptable_#{identifier}", &block)
|
||||
end
|
||||
|
||||
def self.remove(identifier)
|
||||
@all_scriptables = nil
|
||||
undef_method("__scriptable_#{identifier}")
|
||||
end
|
||||
|
||||
def self.all
|
||||
@all_scriptables ||=
|
||||
DiscourseAutomation::Scriptable.instance_methods(false).grep(/^__scriptable_/)
|
||||
end
|
||||
end
|
||||
end
|
24
plugins/automation/lib/discourse_automation/scripts.rb
Normal file
24
plugins/automation/lib/discourse_automation/scripts.rb
Normal file
@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAutomation
|
||||
module Scripts
|
||||
ADD_USER_TO_GROUP_THROUGH_CUSTOM_FIELD = "add_user_to_group_through_custom_field"
|
||||
APPEND_LAST_CHECKED_BY = "append_last_checked_by"
|
||||
APPEND_LAST_EDITED_BY = "append_last_edited_by"
|
||||
AUTO_RESPONDER = "auto_responder"
|
||||
AUTO_TAG_TOPIC = "auto_tag_topic"
|
||||
BANNER_TOPIC = "banner_topic"
|
||||
CLOSE_TOPIC = "close_topic"
|
||||
FLAG_POST_ON_WORDS = "flag_post_on_words"
|
||||
GIFT_EXCHANGE = "gift_exchange"
|
||||
GROUP_CATEGORY_NOTIFICATION_DEFAULT = "group_category_notification_default"
|
||||
PIN_TOPIC = "pin_topic"
|
||||
POST = "post"
|
||||
SEND_PMS = "send_pms"
|
||||
SUSPEND_USER_BY_EMAIL = "suspend_user_by_email"
|
||||
TOPIC_REQUIRED_WORDS = "topic_required_words"
|
||||
USER_GLOBAL_NOTICE = "user_global_notice"
|
||||
USER_GROUP_MEMBERSHIP_THROUGH_BADGE = "user_group_membership_through_badge"
|
||||
ZAPIER_WEBHOOK = "zapier_webhook"
|
||||
end
|
||||
end
|
@ -0,0 +1,74 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# This script takes the name of a User Custom Field containing a group name.
|
||||
# On each run, it ensures that each user belongs to the group name given by that UCF (NOTE: group full_name, not name).
|
||||
#
|
||||
# In other words, it designates a certain User Custom Field to act as
|
||||
# a "pointer" to a group that the user should belong to, and adds users as needed.
|
||||
|
||||
DiscourseAutomation::Scriptable.add(
|
||||
DiscourseAutomation::Scripts::ADD_USER_TO_GROUP_THROUGH_CUSTOM_FIELD,
|
||||
) do
|
||||
field :custom_field_name, component: :custom_field, required: true
|
||||
|
||||
version 1
|
||||
|
||||
triggerables %i[recurring user_first_logged_in]
|
||||
|
||||
script do |trigger, fields|
|
||||
custom_field_name = fields.dig("custom_field_name", "value")
|
||||
|
||||
case trigger["kind"]
|
||||
when DiscourseAutomation::Triggers::API_CALL, DiscourseAutomation::Triggers::RECURRING
|
||||
query =
|
||||
DB.query(<<-SQL, prefix: ::User::USER_FIELD_PREFIX, custom_field_name: custom_field_name)
|
||||
SELECT u.id as user_id, g.id as group_id
|
||||
FROM users u
|
||||
JOIN user_custom_fields ucf
|
||||
ON u.id = ucf.user_id
|
||||
AND ucf.name = CONCAT(:prefix, :custom_field_name)
|
||||
JOIN groups g
|
||||
on g.full_name ilike ucf.value
|
||||
FULL OUTER JOIN group_users gu
|
||||
ON gu.user_id = u.id
|
||||
AND gu.group_id = g.id
|
||||
WHERE gu.id is null
|
||||
AND u.active = true
|
||||
ORDER BY 1, 2
|
||||
SQL
|
||||
|
||||
groups_by_id = {}
|
||||
|
||||
User
|
||||
.where(id: query.map(&:user_id))
|
||||
.order(:id)
|
||||
.zip(query) do |user, query_row|
|
||||
group_id = query_row.group_id
|
||||
group = groups_by_id[group_id] ||= Group.find(group_id)
|
||||
|
||||
group.add(user)
|
||||
GroupActionLogger.new(Discourse.system_user, group).log_add_user_to_group(user)
|
||||
end
|
||||
when DiscourseAutomation::Triggers::USER_FIRST_LOGGED_IN
|
||||
group_name =
|
||||
DB.query_single(
|
||||
<<-SQL,
|
||||
SELECT value
|
||||
FROM user_custom_fields ucf
|
||||
WHERE ucf.user_id = :user_id AND ucf.name = CONCAT(:prefix, :custom_field_name)
|
||||
SQL
|
||||
prefix: ::User::USER_FIELD_PREFIX,
|
||||
custom_field_name: custom_field_name,
|
||||
user_id: trigger["user"].id,
|
||||
).first
|
||||
next if !group_name
|
||||
|
||||
group = Group.find_by(full_name: group_name)
|
||||
next if !group
|
||||
|
||||
user = trigger["user"]
|
||||
group.add(user)
|
||||
GroupActionLogger.new(Discourse.system_user, group).log_add_user_to_group(user)
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::APPEND_LAST_CHECKED_BY) do
|
||||
version 1
|
||||
|
||||
triggerables [:after_post_cook]
|
||||
|
||||
script do |context|
|
||||
post = context["post"]
|
||||
topic = post.topic
|
||||
|
||||
cooked = context["cooked"]
|
||||
doc = Loofah.fragment(cooked)
|
||||
node = doc.css("blockquote.discourse-automation").first
|
||||
|
||||
if node.blank?
|
||||
node = doc.document.create_element("blockquote")
|
||||
node["class"] = "discourse-automation"
|
||||
doc.add_child(node)
|
||||
end
|
||||
|
||||
username = topic.custom_fields[DiscourseAutomation::TOPIC_LAST_CHECKED_BY]
|
||||
checked_at = topic.custom_fields[DiscourseAutomation::TOPIC_LAST_CHECKED_AT]
|
||||
|
||||
if username.present? && checked_at.present?
|
||||
checked_at = DateTime.parse(checked_at)
|
||||
|
||||
date_time =
|
||||
"[date=#{checked_at.to_date} time=#{checked_at.strftime("%H:%M:%S")} timezone=UTC]"
|
||||
node.inner_html +=
|
||||
PrettyText.cook(
|
||||
I18n.t(
|
||||
"discourse_automation.scriptables.append_last_checked_by.text",
|
||||
username: username,
|
||||
date_time: date_time,
|
||||
),
|
||||
).html_safe
|
||||
end
|
||||
|
||||
summary_tag =
|
||||
"<summary>#{I18n.t("discourse_automation.scriptables.append_last_checked_by.summary")}</summary>"
|
||||
button_tag =
|
||||
"<input type=\"button\" value=\"#{I18n.t("discourse_automation.scriptables.append_last_checked_by.button_text")}\" class=\"btn btn-checked\" />"
|
||||
node.inner_html +=
|
||||
"<details>#{summary_tag}#{I18n.t("discourse_automation.scriptables.append_last_checked_by.details")}#{button_tag}</details>"
|
||||
|
||||
doc.try(:to_html)
|
||||
end
|
||||
end
|
@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::APPEND_LAST_EDITED_BY) do
|
||||
version 1
|
||||
|
||||
triggerables [:after_post_cook]
|
||||
|
||||
script do |context|
|
||||
post = context["post"]
|
||||
post_revision = post.revisions.where("user_id > 0").last
|
||||
username = post_revision&.user&.username || post.username
|
||||
updated_at = post_revision&.updated_at || post.updated_at
|
||||
|
||||
cooked = context["cooked"]
|
||||
doc = Loofah.fragment(cooked)
|
||||
|
||||
node = doc.css("blockquote.discourse-automation").first
|
||||
if node.blank?
|
||||
node = doc.document.create_element("blockquote")
|
||||
node["class"] = "discourse-automation"
|
||||
doc.add_child(node)
|
||||
end
|
||||
|
||||
date_time = "[date=#{updated_at.to_date} time=#{updated_at.strftime("%H:%M:%S")} timezone=UTC]"
|
||||
node.inner_html +=
|
||||
PrettyText.cook(
|
||||
I18n.t(
|
||||
"discourse_automation.scriptables.append_last_edited_by.text",
|
||||
username: username,
|
||||
date_time: date_time,
|
||||
),
|
||||
).html_safe
|
||||
|
||||
doc.try(:to_html)
|
||||
end
|
||||
end
|
@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::AUTO_RESPONDER) do
|
||||
field :word_answer_list, component: :"key-value", accepts_placeholders: true
|
||||
field :answering_user, component: :user
|
||||
field :once, component: :boolean
|
||||
|
||||
version 1
|
||||
|
||||
triggerables %i[post_created_edited pm_created]
|
||||
|
||||
placeholder :sender_username
|
||||
placeholder :word
|
||||
|
||||
script do |context, fields, automation|
|
||||
key = DiscourseAutomation::AUTO_RESPONDER_TRIGGERED_IDS
|
||||
|
||||
answering_username = fields.dig("answering_user", "value") || Discourse.system_user.username
|
||||
placeholders = { sender_username: answering_username }
|
||||
post = context["post"]
|
||||
next if !post.topic
|
||||
|
||||
next if fields.dig("once", "value") && post.topic.custom_fields[key]&.include?(automation.id)
|
||||
|
||||
answers = Set.new
|
||||
word_answer_list_json = fields.dig("word_answer_list", "value")
|
||||
next if word_answer_list_json.blank?
|
||||
|
||||
word_answer_list = JSON.parse(word_answer_list_json)
|
||||
next if word_answer_list.blank?
|
||||
|
||||
word_answer_list.each do |word_answer_pair|
|
||||
if word_answer_pair["key"].blank?
|
||||
answers.add(word_answer_pair)
|
||||
next
|
||||
end
|
||||
|
||||
if post.is_first_post?
|
||||
if match = post.topic.title.match(/\b(#{word_answer_pair["key"]})\b/i)
|
||||
word_answer_pair["key"] = match.captures.first
|
||||
answers.add(word_answer_pair)
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
if match = post.raw.match(/\b(#{word_answer_pair["key"]})\b/i)
|
||||
word_answer_pair["key"] = match.captures.first
|
||||
answers.add(word_answer_pair)
|
||||
end
|
||||
end
|
||||
|
||||
next if answers.blank?
|
||||
|
||||
answering_user = User.find_by(username: answering_username)
|
||||
next if post.user == answering_user
|
||||
|
||||
replies =
|
||||
post
|
||||
.replies
|
||||
.where(user_id: answering_user.id, deleted_at: nil)
|
||||
.secured(Guardian.new(post.user))
|
||||
|
||||
next if replies.present?
|
||||
|
||||
answers =
|
||||
answers
|
||||
.to_a
|
||||
.map do |answer|
|
||||
utils.apply_placeholders(answer["value"], placeholders.merge(key: answer["key"]))
|
||||
end
|
||||
.join("\n\n")
|
||||
|
||||
value = (Array(post.topic.custom_fields[key]) << automation.id).compact.uniq
|
||||
post.topic.custom_fields[key] = value
|
||||
post.topic.save_custom_fields
|
||||
|
||||
PostCreator.create!(
|
||||
answering_user,
|
||||
topic_id: post.topic.id,
|
||||
reply_to_post_number: post.post_number,
|
||||
raw: answers,
|
||||
skip_validations: true,
|
||||
)
|
||||
end
|
||||
end
|
@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::AUTO_TAG_TOPIC) do
|
||||
field :tags, component: :tags, required: true
|
||||
|
||||
version 1
|
||||
|
||||
triggerables %i[post_created_edited pm_created]
|
||||
|
||||
script do |context, fields|
|
||||
post = context["post"]
|
||||
|
||||
next if !post.is_first_post?
|
||||
next if !post.topic
|
||||
next unless topic = Topic.find_by(id: post.topic.id)
|
||||
|
||||
tags = fields.dig("tags", "value")
|
||||
|
||||
DiscourseTagging.tag_topic_by_names(topic, Guardian.new(post.user), tags, append: true)
|
||||
end
|
||||
end
|
@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::BANNER_TOPIC) do
|
||||
field :topic_id, component: :text, required: true
|
||||
field :banner_until, component: :date_time
|
||||
field :user, component: :user
|
||||
|
||||
version 1
|
||||
|
||||
triggerables [:point_in_time]
|
||||
|
||||
script do |_, fields|
|
||||
next unless topic_id = fields.dig("topic_id", "value")
|
||||
next unless topic = Topic.find_by(id: topic_id)
|
||||
|
||||
banner_until = fields.dig("banner_until", "value") || nil
|
||||
|
||||
username = fields.dig("user", "value") || Discourse.system_user.username
|
||||
next unless user = User.find_by(username: username)
|
||||
|
||||
topic.make_banner!(user, banner_until)
|
||||
end
|
||||
end
|
@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::CLOSE_TOPIC) do
|
||||
field :topic, component: :text, required: true, triggerable: :point_in_time
|
||||
field :message, component: :text
|
||||
field :user, component: :user
|
||||
|
||||
version 1
|
||||
|
||||
triggerables %i[point_in_time stalled_wiki]
|
||||
|
||||
script do |context, fields|
|
||||
message = fields.dig("message", "value")
|
||||
username = fields.dig("user", "value") || Discourse.system_user.username
|
||||
|
||||
topic_id = fields.dig("topic", "value") || context.dig("topic", "id")
|
||||
next unless topic_id
|
||||
next unless topic = Topic.find_by(id: topic_id)
|
||||
|
||||
user = User.find_by_username(username)
|
||||
next unless user
|
||||
next unless Guardian.new(user).can_moderate?(topic)
|
||||
|
||||
topic.update_status("closed", true, user)
|
||||
|
||||
if message.present?
|
||||
topic_closed_post = topic.posts.where(action_code: "closed.enabled").last
|
||||
topic_closed_post.raw = message
|
||||
|
||||
# FIXME: when there is proper error handling and logging in automation,
|
||||
# remove this and allow validations to take place
|
||||
topic_closed_post.skip_validation = true
|
||||
|
||||
topic_closed_post.save!
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,38 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::FLAG_POST_ON_WORDS) do
|
||||
field :words, component: :text_list, required: true
|
||||
|
||||
version 1
|
||||
|
||||
triggerables %i[post_created_edited]
|
||||
|
||||
script do |trigger, fields|
|
||||
post = trigger["post"]
|
||||
|
||||
Array(fields.dig("words", "value")).each do |list|
|
||||
words = list.split(",")
|
||||
count = words.inject(0) { |acc, word| post.raw.match?(/#{word}/i) ? acc + 1 : acc }
|
||||
next if count < words.length
|
||||
|
||||
has_trust_level = post.user.has_trust_level?(TrustLevel[2])
|
||||
trusted_user =
|
||||
has_trust_level ||
|
||||
ReviewableFlaggedPost.where(
|
||||
status: Reviewable.statuses[:rejected],
|
||||
target_created_by: post.user,
|
||||
).exists?
|
||||
next if trusted_user
|
||||
|
||||
message =
|
||||
I18n.t("discourse_automation.scriptables.flag_post_on_words.flag_message", words: list)
|
||||
PostActionCreator.new(
|
||||
Discourse.system_user,
|
||||
post,
|
||||
PostActionType.types[:spam],
|
||||
message: message,
|
||||
queue_for_review: true,
|
||||
).perform
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,72 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::GIFT_EXCHANGE) do
|
||||
placeholder :year
|
||||
placeholder :giftee_username
|
||||
placeholder :gifter_username
|
||||
|
||||
field :giftee_assignment_messages, component: :pms, accepts_placeholders: true, required: true
|
||||
field :gift_exchangers_group, component: :group, required: true
|
||||
|
||||
version 17
|
||||
|
||||
triggerables %i[point_in_time]
|
||||
|
||||
script do |_, fields, automation|
|
||||
now = Time.zone.now
|
||||
group_id = fields.dig("gift_exchangers_group", "value")
|
||||
|
||||
unless group = Group.find_by(id: group_id)
|
||||
Rails.logger.warn "[discourse-automation] Couldn’t find group with id #{group_id}"
|
||||
next
|
||||
end
|
||||
|
||||
cf_name = "#{group.name}-gifts-were-exchanged-#{automation.id}-#{version}-#{now.year}"
|
||||
if group.custom_fields[cf_name].present?
|
||||
Rails.logger.warn "[discourse-automation] Gift exchange script has already been run on #{cf_name} this year #{now.year} for this script version #{version}"
|
||||
next
|
||||
end
|
||||
|
||||
usernames = group.users.pluck(:username)
|
||||
|
||||
if usernames.size < 3
|
||||
Rails.logger.warn "[discourse-automation] Gift exchange needs at least 3 users in a group"
|
||||
next
|
||||
end
|
||||
|
||||
usernames.shuffle!
|
||||
usernames << usernames[0]
|
||||
|
||||
# shuffle the pairs to prevent prying eyes to identify matches by looking at the timestamps of the topics
|
||||
pairs = usernames.each_cons(2).to_a.shuffle
|
||||
|
||||
pairs.each do |gifter, giftee|
|
||||
placeholders = { year: now.year.to_s, gifter_username: gifter, giftee_username: giftee }
|
||||
|
||||
Array(fields.dig("giftee_assignment_messages", "value")).each do |giftee_assignment_message|
|
||||
if giftee_assignment_message["title"].blank?
|
||||
Rails.logger.warn "[discourse-automation] Gift exchange requires a title for the PM"
|
||||
next
|
||||
end
|
||||
|
||||
if giftee_assignment_message["raw"].blank?
|
||||
Rails.logger.warn "[discourse-automation] Gift exchange requires a raw for the PM"
|
||||
next
|
||||
end
|
||||
|
||||
raw = utils.apply_placeholders(giftee_assignment_message["raw"], placeholders)
|
||||
title = utils.apply_placeholders(giftee_assignment_message["title"], placeholders)
|
||||
|
||||
utils.send_pm(
|
||||
{ target_usernames: Array(gifter), title: title, raw: raw },
|
||||
delay: giftee_assignment_message["delay"],
|
||||
prefers_encrypt: giftee_assignment_message["prefers_encrypt"],
|
||||
automation_id: automation.id,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
group.custom_fields[cf_name] = true
|
||||
group.save_custom_fields
|
||||
end
|
||||
end
|
@ -0,0 +1,60 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(
|
||||
DiscourseAutomation::Scripts::GROUP_CATEGORY_NOTIFICATION_DEFAULT,
|
||||
) do
|
||||
version 1
|
||||
|
||||
field :group, component: :group
|
||||
field :notification_level, component: :category_notification_level
|
||||
field :update_existing_members, component: :boolean
|
||||
|
||||
triggerables %i[category_created_edited]
|
||||
|
||||
script do |context, fields|
|
||||
category_id = context["category"].id
|
||||
group_id = fields.dig("group", "value")
|
||||
notification_level = fields.dig("notification_level", "value")
|
||||
|
||||
unless group = Group.find_by(id: group_id)
|
||||
Rails.logger.warn "[discourse-automation] Couldn’t find group with id #{group_id}"
|
||||
next
|
||||
end
|
||||
|
||||
GroupCategoryNotificationDefault
|
||||
.find_or_initialize_by(group_id: group_id, category_id: category_id)
|
||||
.tap do |gc|
|
||||
gc.notification_level = notification_level
|
||||
gc.save!
|
||||
end
|
||||
|
||||
if fields.dig("update_existing_members", "value")
|
||||
group
|
||||
.users
|
||||
.select(:id, :user_id)
|
||||
.find_in_batches do |batch|
|
||||
user_ids = batch.pluck(:user_id)
|
||||
|
||||
category_users = []
|
||||
existing_users =
|
||||
CategoryUser.where(category_id: category_id, user_id: user_ids).where(
|
||||
"notification_level IS NOT NULL",
|
||||
)
|
||||
skip_user_ids = existing_users.pluck(:user_id)
|
||||
|
||||
batch.each do |group_user|
|
||||
next if skip_user_ids.include?(group_user.user_id)
|
||||
category_users << {
|
||||
category_id: category_id,
|
||||
user_id: group_user.user_id,
|
||||
notification_level: notification_level,
|
||||
}
|
||||
end
|
||||
|
||||
next if category_users.blank?
|
||||
|
||||
CategoryUser.insert_all!(category_users)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::PIN_TOPIC) do
|
||||
field :pinnable_topic, component: :text, required: true
|
||||
field :pinned_until, component: :date_time
|
||||
field :pinned_globally, component: :boolean
|
||||
|
||||
version 1
|
||||
|
||||
triggerables [:point_in_time]
|
||||
|
||||
script do |_context, fields|
|
||||
next unless topic_id = fields.dig("pinnable_topic", "value")
|
||||
next unless topic = Topic.find_by(id: topic_id)
|
||||
|
||||
pinned_globally = fields.dig("pinned_globally", "value") || false
|
||||
pinned_until = fields.dig("pinned_until", "value") || nil
|
||||
|
||||
topic.update_pinned(true, pinned_globally, pinned_until)
|
||||
end
|
||||
end
|
66
plugins/automation/lib/discourse_automation/scripts/post.rb
Normal file
66
plugins/automation/lib/discourse_automation/scripts/post.rb
Normal file
@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::POST) do
|
||||
version 1
|
||||
|
||||
placeholder :creator_username
|
||||
|
||||
field :creator, component: :user
|
||||
field :creator, component: :user, triggerable: :user_updated, accepted_contexts: [:updated_user]
|
||||
|
||||
field :topic, component: :text, required: true
|
||||
field :post, component: :post, required: true, accepts_placeholders: true
|
||||
|
||||
placeholder :creator_username
|
||||
placeholder :updated_user_username, triggerable: :user_updated
|
||||
placeholder :updated_user_name, triggerable: :user_updated
|
||||
|
||||
triggerables %i[recurring point_in_time user_updated]
|
||||
|
||||
script do |context, fields, automation|
|
||||
creator_username = fields.dig("creator", "value")
|
||||
creator_username = context["user"]&.username if creator_username == "updated_user"
|
||||
creator_username ||= Discourse.system_user.username
|
||||
|
||||
topic_id = fields.dig("topic", "value")
|
||||
post_raw = fields.dig("post", "value")
|
||||
|
||||
placeholders = { creator_username: creator_username }.merge(context["placeholders"] || {})
|
||||
creator = User.find_by(username: creator_username)
|
||||
topic = Topic.find_by(id: topic_id)
|
||||
|
||||
if !topic || topic.closed? || topic.archived?
|
||||
Rails.logger.warn "[discourse-automation] topic with id: `#{topic_id}` was not found"
|
||||
next
|
||||
end
|
||||
|
||||
if context["kind"] == DiscourseAutomation::Triggers::USER_UPDATED
|
||||
user = context["user"]
|
||||
user_data = context["user_data"]
|
||||
user_profile_data = user_data[:profile_data] || {}
|
||||
user_custom_fields = {}
|
||||
user_data[:custom_fields]&.each do |k, v|
|
||||
user_custom_fields[k.gsub(/\s+/, "_").underscore] = v
|
||||
end
|
||||
user = User.find(context["user"].id)
|
||||
placeholders["username"] = user.username
|
||||
placeholders["name"] = user.name
|
||||
placeholders["updated_user_username"] = user.username
|
||||
placeholders["updated_user_name"] = user.name
|
||||
placeholders = placeholders.merge(user_profile_data, user_custom_fields)
|
||||
end
|
||||
|
||||
post_raw = utils.apply_placeholders(post_raw, placeholders)
|
||||
|
||||
if !creator
|
||||
Rails.logger.warn "[discourse-automation] creator with username: `#{creator_username}` was not found"
|
||||
next
|
||||
end
|
||||
|
||||
new_post = PostCreator.new(creator, topic_id: topic_id, raw: post_raw).create!
|
||||
|
||||
if context["kind"] == DiscourseAutomation::Triggers::USER_UPDATED && new_post.persisted?
|
||||
user.user_custom_fields.create(name: automation.name, value: "true")
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,55 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::SEND_PMS) do
|
||||
version 1
|
||||
|
||||
placeholder :sender_username
|
||||
placeholder :receiver_username
|
||||
|
||||
field :sender, component: :user
|
||||
field :receiver, component: :user, triggerable: :recurring
|
||||
field :sendable_pms, component: :pms, accepts_placeholders: true, required: true
|
||||
|
||||
triggerables %i[
|
||||
user_badge_granted
|
||||
user_added_to_group
|
||||
stalled_wiki
|
||||
recurring
|
||||
user_promoted
|
||||
api_call
|
||||
user_removed_from_group
|
||||
]
|
||||
|
||||
script do |context, fields, automation|
|
||||
sender_username = fields.dig("sender", "value") || Discourse.system_user.username
|
||||
|
||||
placeholders = { sender_username: sender_username }.merge(context["placeholders"] || {})
|
||||
|
||||
usernames = context["usernames"] || []
|
||||
|
||||
# optional field when using recurring triggerable
|
||||
if u = fields.dig("receiver", "value")
|
||||
usernames << u
|
||||
end
|
||||
|
||||
usernames.compact.uniq.each do |username|
|
||||
placeholders[:receiver_username] = username
|
||||
Array(fields.dig("sendable_pms", "value")).each do |sendable|
|
||||
next if !sendable["title"] || !sendable["raw"]
|
||||
|
||||
pm = {}
|
||||
pm["title"] = utils.apply_placeholders(sendable["title"], placeholders)
|
||||
pm["raw"] = utils.apply_placeholders(sendable["raw"], placeholders)
|
||||
pm["target_usernames"] = Array(username)
|
||||
|
||||
utils.send_pm(
|
||||
pm,
|
||||
sender: sender_username,
|
||||
automation_id: automation.id,
|
||||
delay: sendable["delay"],
|
||||
prefers_encrypt: !!sendable["prefers_encrypt"],
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::SUSPEND_USER_BY_EMAIL) do
|
||||
version 1
|
||||
|
||||
triggerables %i[api_call]
|
||||
|
||||
field :reason, component: :text, required: true
|
||||
field :suspend_until, component: :date_time, required: true
|
||||
field :actor, component: :user
|
||||
|
||||
script do |context, fields|
|
||||
email = context["email"]
|
||||
next unless target = UserEmail.find_by(email: email)&.user
|
||||
|
||||
next if target.suspended?
|
||||
|
||||
unless actor =
|
||||
User.find_by(username: fields.dig("actor", "value") || Discourse.system_user.username)
|
||||
next
|
||||
end
|
||||
guardian = Guardian.new(actor)
|
||||
guardian.ensure_can_suspend!(target)
|
||||
|
||||
suspend_until = context["suspend_until"].presence || fields.dig("suspend_until", "value")
|
||||
reason = context["reason"].presence || fields.dig("reason", "value")
|
||||
|
||||
User.transaction do
|
||||
target.suspended_till = suspend_until
|
||||
target.suspended_at = DateTime.now
|
||||
target.save!
|
||||
|
||||
StaffActionLogger.new(actor).log_user_suspend(target, reason)
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::TOPIC_REQUIRED_WORDS) do
|
||||
field :words, component: :text_list
|
||||
|
||||
version 1
|
||||
|
||||
triggerables %i[topic]
|
||||
end
|
@ -0,0 +1,59 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::USER_GLOBAL_NOTICE) do
|
||||
field :notice, component: :message, required: true, accepts_placeholders: true
|
||||
field :level,
|
||||
component: :choices,
|
||||
extra: {
|
||||
content:
|
||||
%w[success error warning info].map do |level|
|
||||
{
|
||||
id: level,
|
||||
name: "discourse_automation.scriptables.user_global_notice.levels.#{level}",
|
||||
}
|
||||
end,
|
||||
}
|
||||
|
||||
version 1
|
||||
|
||||
triggerables [:stalled_topic]
|
||||
triggerables [:first_accepted_solution] if defined?(DiscourseSolved)
|
||||
|
||||
placeholder :username
|
||||
|
||||
script do |context, fields, automation|
|
||||
placeholders = {}.merge(context["placeholders"] || {})
|
||||
|
||||
if context["kind"] == DiscourseAutomation::Triggers::STALLED_TOPIC
|
||||
user = context["topic"].user
|
||||
placeholders["username"] = user.username
|
||||
elsif context["kind"] == "first_accepted_solution"
|
||||
username = context["usernames"][0]
|
||||
user = User.find_by(username: username)
|
||||
placeholders["username"] = username
|
||||
end
|
||||
|
||||
notice = utils.apply_placeholders(fields.dig("notice", "value") || "", placeholders)
|
||||
level = fields.dig("level", "value")
|
||||
|
||||
begin
|
||||
DiscourseAutomation::UserGlobalNotice.upsert(
|
||||
{
|
||||
identifier: automation.id,
|
||||
notice: notice,
|
||||
user_id: user.id,
|
||||
level: level,
|
||||
created_at: Time.now,
|
||||
updated_at: Time.now,
|
||||
},
|
||||
unique_by: "idx_discourse_automation_user_global_notices",
|
||||
)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
# do nothing
|
||||
end
|
||||
end
|
||||
|
||||
on_reset do |automation|
|
||||
DiscourseAutomation::UserGlobalNotice.where(identifier: automation.id).destroy_all
|
||||
end
|
||||
end
|
@ -0,0 +1,116 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(
|
||||
DiscourseAutomation::Scripts::USER_GROUP_MEMBERSHIP_THROUGH_BADGE,
|
||||
) do
|
||||
version 2
|
||||
|
||||
field :badge,
|
||||
component: :choices,
|
||||
extra: {
|
||||
content:
|
||||
Badge.order(:name).select(:id, :name).map { |b| { id: b.id, translated_name: b.name } },
|
||||
},
|
||||
required: true
|
||||
field :group, component: :group, required: true, extra: { ignore_automatic: true }
|
||||
field :update_user_title_and_flair, component: :boolean
|
||||
field :remove_members_without_badge, component: :boolean
|
||||
|
||||
triggerables %i[recurring user_first_logged_in]
|
||||
|
||||
script do |context, fields|
|
||||
badge_id = fields.dig("badge", "value")
|
||||
group_id = fields.dig("group", "value")
|
||||
update_user_title_and_flair = fields.dig("update_user_title_and_flair", "value")
|
||||
remove_members_without_badge = fields.dig("remove_members_without_badge", "value")
|
||||
current_user = context["user"]
|
||||
bulk_modify_start_count =
|
||||
DiscourseAutomation::USER_GROUP_MEMBERSHIP_THROUGH_BADGE_BULK_MODIFY_START_COUNT
|
||||
|
||||
badge = Badge.find_by(id: badge_id)
|
||||
unless badge
|
||||
Rails.logger.warn("[discourse-automation] Couldn’t find badge with id #{badge_id}")
|
||||
next
|
||||
end
|
||||
|
||||
group = Group.find_by(id: group_id)
|
||||
unless group
|
||||
Rails.logger.warn("[discourse-automation] Couldn’t find group with id #{group_id}")
|
||||
next
|
||||
end
|
||||
|
||||
query_options = { group_id: group.id, badge_id: badge.id }
|
||||
|
||||
# IDs of users who currently have badge but not members of target group
|
||||
user_ids_to_add_query = +<<~SQL
|
||||
SELECT u.id AS user_id
|
||||
FROM users u
|
||||
JOIN user_badges ub ON u.id = ub.user_id
|
||||
LEFT JOIN group_users gu ON u.id = gu.user_id AND gu.group_id = :group_id
|
||||
WHERE ub.badge_id = :badge_id AND gu.user_id IS NULL
|
||||
SQL
|
||||
|
||||
if current_user
|
||||
user_ids_to_add_query << " AND u.id = :user_id"
|
||||
query_options[:user_id] = current_user.id
|
||||
end
|
||||
|
||||
user_ids_to_add = DB.query_single(user_ids_to_add_query, query_options)
|
||||
|
||||
if user_ids_to_add.count < bulk_modify_start_count
|
||||
User
|
||||
.where(id: user_ids_to_add)
|
||||
.each do |user|
|
||||
group.add(user)
|
||||
GroupActionLogger.new(Discourse.system_user, group).log_add_user_to_group(user)
|
||||
end
|
||||
else
|
||||
group.bulk_add(user_ids_to_add)
|
||||
end
|
||||
|
||||
if update_user_title_and_flair && user_ids_to_add.present?
|
||||
DB.exec(<<~SQL, ids: user_ids_to_add, title: group.title, flair_group_id: group.id)
|
||||
UPDATE users
|
||||
SET title = :title, flair_group_id = :flair_group_id
|
||||
WHERE id IN (:ids)
|
||||
SQL
|
||||
end
|
||||
|
||||
next unless remove_members_without_badge
|
||||
|
||||
# IDs of users who are currently target group members without the badge
|
||||
user_ids_to_remove_query = +<<~SQL
|
||||
SELECT u.id AS user_id
|
||||
FROM users u
|
||||
JOIN group_users gu ON u.id = gu.user_id
|
||||
LEFT JOIN user_badges ub ON u.id = ub.user_id AND ub.badge_id = :badge_id
|
||||
WHERE gu.group_id = :group_id AND ub.user_id IS NULL
|
||||
SQL
|
||||
|
||||
if current_user
|
||||
user_ids_to_remove_query << " AND u.id = :user_id"
|
||||
query_options[:user_id] ||= current_user.id
|
||||
end
|
||||
|
||||
user_ids_to_remove = DB.query_single(user_ids_to_remove_query, query_options)
|
||||
|
||||
if user_ids_to_remove.count < bulk_modify_start_count
|
||||
User
|
||||
.where(id: user_ids_to_remove)
|
||||
.each do |user|
|
||||
group.remove(user)
|
||||
GroupActionLogger.new(Discourse.system_user, group).log_remove_user_from_group(user)
|
||||
end
|
||||
else
|
||||
group.bulk_remove(user_ids_to_remove)
|
||||
end
|
||||
|
||||
if update_user_title_and_flair && user_ids_to_remove.present?
|
||||
DB.exec(<<~SQL, ids: user_ids_to_remove, title: group.title, flair_group_id: group.id)
|
||||
UPDATE users
|
||||
SET title = NULL, flair_group_id = NULL
|
||||
WHERE id IN (:ids) AND (flair_group_id = :flair_group_id OR title = :title)
|
||||
SQL
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Scriptable.add(DiscourseAutomation::Scripts::ZAPIER_WEBHOOK) do
|
||||
field :webhook_url, component: :text, required: true
|
||||
|
||||
version 1
|
||||
|
||||
triggerables %i[user_promoted user_added_to_group user_badge_granted user_removed_from_group]
|
||||
|
||||
script do |context, fields|
|
||||
webhook_url = fields.dig("webhook_url", "value")
|
||||
|
||||
unless webhook_url&.start_with?("https://hooks.zapier.com/hooks/catch/")
|
||||
Rails.logger.warn "[discourse-automation] #{webhook_url} is not a valid Zapier webhook URL, expecting an URL starting with https://hooks.zapier.com/hooks/catch/"
|
||||
next
|
||||
end
|
||||
|
||||
Jobs.enqueue(
|
||||
:discourse_automation_call_zapier_webhook,
|
||||
webhook_url: webhook_url,
|
||||
context: context,
|
||||
)
|
||||
end
|
||||
end
|
118
plugins/automation/lib/discourse_automation/triggerable.rb
Normal file
118
plugins/automation/lib/discourse_automation/triggerable.rb
Normal file
@ -0,0 +1,118 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAutomation
|
||||
class Triggerable
|
||||
attr_reader :fields, :name, :not_found, :settings, :automation
|
||||
|
||||
MANUAL_TRIGGER_KEY = :manual_trigger
|
||||
|
||||
def initialize(name, automation = nil)
|
||||
@name = name
|
||||
@placeholders = []
|
||||
@fields = []
|
||||
@settings = { MANUAL_TRIGGER_KEY => false }
|
||||
@on_update_block = proc {}
|
||||
@on_call_block = proc {}
|
||||
@not_found = false
|
||||
@validations = []
|
||||
@automation = automation
|
||||
|
||||
eval! if @name
|
||||
end
|
||||
|
||||
def id
|
||||
"trigger"
|
||||
end
|
||||
|
||||
def scriptable?
|
||||
false
|
||||
end
|
||||
|
||||
def triggerable?
|
||||
true
|
||||
end
|
||||
|
||||
def validate(&block)
|
||||
@validations << block
|
||||
end
|
||||
|
||||
def valid?(automation)
|
||||
@validations.each { |block| automation.instance_exec(&block) }
|
||||
|
||||
automation.errors.blank?
|
||||
end
|
||||
|
||||
def placeholders
|
||||
@placeholders.uniq.compact.map(&:to_sym)
|
||||
end
|
||||
|
||||
def placeholder(*args)
|
||||
if args.present?
|
||||
@placeholders << args[0]
|
||||
elsif block_given?
|
||||
@placeholders =
|
||||
@placeholders.concat(Array(yield(@automation.serialized_fields, @automation)))
|
||||
end
|
||||
end
|
||||
|
||||
def field(name, component:, **options)
|
||||
@fields << {
|
||||
name: name,
|
||||
component: component,
|
||||
extra: {
|
||||
},
|
||||
accepts_placeholders: false,
|
||||
accepted_contexts: [],
|
||||
required: false,
|
||||
}.merge(options || {})
|
||||
end
|
||||
|
||||
def setting(key, value)
|
||||
@settings[key] = value
|
||||
end
|
||||
|
||||
def enable_manual_trigger
|
||||
setting(MANUAL_TRIGGER_KEY, true)
|
||||
end
|
||||
|
||||
def components
|
||||
fields.map { |f| f[:component] }.uniq
|
||||
end
|
||||
|
||||
def eval!
|
||||
begin
|
||||
public_send("__triggerable_#{name.underscore}")
|
||||
rescue NoMethodError
|
||||
@not_found = true
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def on_call(&block)
|
||||
if block_given?
|
||||
@on_call_block = block
|
||||
else
|
||||
@on_call_block
|
||||
end
|
||||
end
|
||||
|
||||
def on_update(&block)
|
||||
if block_given?
|
||||
@on_update_block = block
|
||||
else
|
||||
@on_update_block
|
||||
end
|
||||
end
|
||||
|
||||
def self.add(identifier, &block)
|
||||
@all_triggers = nil
|
||||
define_method("__triggerable_#{identifier}", block || proc {})
|
||||
end
|
||||
|
||||
def self.all
|
||||
@all_triggers ||=
|
||||
DiscourseAutomation::Triggerable.instance_methods(false).grep(/^__triggerable_/)
|
||||
end
|
||||
end
|
||||
end
|
22
plugins/automation/lib/discourse_automation/triggers.rb
Normal file
22
plugins/automation/lib/discourse_automation/triggers.rb
Normal file
@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAutomation
|
||||
module Triggers
|
||||
AFTER_POST_COOK = "after_post_cook"
|
||||
API_CALL = "api_call"
|
||||
CATEGORY_CREATED_EDITED = "category_created_edited"
|
||||
PM_CREATED = "pm_created"
|
||||
POINT_IN_TIME = "point_in_time"
|
||||
POST_CREATED_EDITED = "post_created_edited"
|
||||
RECURRING = "recurring"
|
||||
STALLED_TOPIC = "stalled_topic"
|
||||
STALLED_WIKI = "stalled_wiki"
|
||||
TOPIC = "topic"
|
||||
USER_ADDED_TO_GROUP = "user_added_to_group"
|
||||
USER_BADGE_GRANTED = "user_badge_granted"
|
||||
USER_FIRST_LOGGED_IN = "user_first_logged_in"
|
||||
USER_PROMOTED = "user_promoted"
|
||||
USER_REMOVED_FROM_GROUP = "user_removed_from_group"
|
||||
USER_UPDATED = "user_updated"
|
||||
end
|
||||
end
|
@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::AFTER_POST_COOK) do
|
||||
field :restricted_category, component: :category
|
||||
field :restricted_tags, component: :tags
|
||||
field :valid_trust_levels, component: :"trust-levels"
|
||||
end
|
@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::API_CALL) {}
|
@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::CATEGORY_CREATED_EDITED) do
|
||||
field :restricted_category, component: :category
|
||||
end
|
@ -0,0 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::PM_CREATED) do
|
||||
field :restricted_user, component: :user
|
||||
field :restricted_group, component: :group
|
||||
field :ignore_staff, component: :boolean
|
||||
field :ignore_automated, component: :boolean
|
||||
field :ignore_group_members, component: :boolean
|
||||
field :valid_trust_levels, component: :"trust-levels"
|
||||
end
|
@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::POINT_IN_TIME) do
|
||||
field :execute_at, component: :date_time, required: true
|
||||
|
||||
on_update do |automation, metadata|
|
||||
# prevents creating a new pending automation on save when date is expired
|
||||
execute_at = metadata.dig("execute_at", "value")
|
||||
if execute_at && execute_at > Time.zone.now
|
||||
automation.pending_automations.create!(execute_at: execute_at)
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::POST_CREATED_EDITED) do
|
||||
field :action_type,
|
||||
component: :choices,
|
||||
extra: {
|
||||
content: [
|
||||
{
|
||||
id: "created",
|
||||
name: "discourse_automation.triggerables.post_created_edited.created",
|
||||
},
|
||||
{ id: "edited", name: "discourse_automation.triggerables.post_created_edited.edited" },
|
||||
],
|
||||
}
|
||||
field :restricted_category, component: :category
|
||||
field :restricted_group, component: :group
|
||||
field :ignore_automated, component: :boolean
|
||||
field :ignore_group_members, component: :boolean
|
||||
field :valid_trust_levels, component: :"trust-levels"
|
||||
field :first_post_only, component: :boolean
|
||||
field :first_topic_only, component: :boolean
|
||||
end
|
@ -0,0 +1,93 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAutomation
|
||||
module Triggers
|
||||
module Recurring
|
||||
RECURRENCE_CHOICES = [
|
||||
{ id: "minute", name: "discourse_automation.triggerables.recurring.frequencies.minute" },
|
||||
{ id: "hour", name: "discourse_automation.triggerables.recurring.frequencies.hour" },
|
||||
{ id: "day", name: "discourse_automation.triggerables.recurring.frequencies.day" },
|
||||
{ id: "weekday", name: "discourse_automation.triggerables.recurring.frequencies.weekday" },
|
||||
{ id: "week", name: "discourse_automation.triggerables.recurring.frequencies.week" },
|
||||
{ id: "month", name: "discourse_automation.triggerables.recurring.frequencies.month" },
|
||||
{ id: "year", name: "discourse_automation.triggerables.recurring.frequencies.year" },
|
||||
]
|
||||
|
||||
def self.setup_pending_automation(automation, fields)
|
||||
automation.pending_automations.destroy_all
|
||||
|
||||
return unless start_date = fields.dig("start_date", "value")
|
||||
return unless interval = fields.dig("recurrence", "value", "interval")
|
||||
return unless frequency = fields.dig("recurrence", "value", "frequency")
|
||||
|
||||
start_date = Time.parse(start_date)
|
||||
byday = start_date.strftime("%A").upcase[0, 2]
|
||||
interval = interval.to_i
|
||||
interval_end = interval + 1
|
||||
|
||||
next_trigger_date =
|
||||
case frequency
|
||||
when "minute"
|
||||
(Time.zone.now + interval.minute).beginning_of_minute
|
||||
when "hour"
|
||||
(Time.zone.now + interval.hour).beginning_of_hour
|
||||
when "day"
|
||||
RRule::Rule
|
||||
.new("FREQ=DAILY;INTERVAL=#{interval}", dtstart: start_date)
|
||||
.between(Time.now, interval_end.days.from_now)
|
||||
.first
|
||||
when "weekday"
|
||||
max_weekends = (interval_end.to_f / 5).ceil
|
||||
RRule::Rule
|
||||
.new("FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR", dtstart: start_date)
|
||||
.between(Time.now.end_of_day, max_weekends.weeks.from_now)
|
||||
.drop(interval - 1)
|
||||
.first
|
||||
when "week"
|
||||
RRule::Rule
|
||||
.new("FREQ=WEEKLY;INTERVAL=#{interval};BYDAY=#{byday}", dtstart: start_date)
|
||||
.between(Time.now.end_of_week, interval_end.weeks.from_now)
|
||||
.first
|
||||
when "month"
|
||||
count = 0
|
||||
(start_date.beginning_of_month.to_date..start_date.end_of_month.to_date).each do |date|
|
||||
count += 1 if date.strftime("%A") == start_date.strftime("%A")
|
||||
break if date.day == start_date.day
|
||||
end
|
||||
RRule::Rule
|
||||
.new("FREQ=MONTHLY;INTERVAL=#{interval};BYDAY=#{count}#{byday}", dtstart: start_date)
|
||||
.between(Time.now, interval_end.months.from_now)
|
||||
.first
|
||||
when "year"
|
||||
RRule::Rule
|
||||
.new("FREQ=YEARLY;INTERVAL=#{interval}", dtstart: start_date)
|
||||
.between(Time.now, interval_end.years.from_now)
|
||||
.first
|
||||
end
|
||||
|
||||
if next_trigger_date && next_trigger_date > Time.zone.now
|
||||
automation.pending_automations.create!(execute_at: next_trigger_date)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::RECURRING) do
|
||||
field :recurrence,
|
||||
component: :period,
|
||||
extra: {
|
||||
content: DiscourseAutomation::Triggers::Recurring::RECURRENCE_CHOICES,
|
||||
},
|
||||
required: true
|
||||
field :start_date, component: :date_time, required: true
|
||||
|
||||
on_update do |automation, fields|
|
||||
DiscourseAutomation::Triggers::Recurring.setup_pending_automation(automation, fields)
|
||||
end
|
||||
on_call do |automation, fields|
|
||||
DiscourseAutomation::Triggers::Recurring.setup_pending_automation(automation, fields)
|
||||
end
|
||||
|
||||
enable_manual_trigger
|
||||
end
|
@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
key = "discourse_automation.triggerables.stalled_topic.durations"
|
||||
ids = %w[PT1H P1D P1W P2W P1M P3M P6M P1Y]
|
||||
duration_choices = ids.map { |id| { id: id, name: "#{key}.#{id}" } }
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::STALLED_TOPIC) do
|
||||
field :categories, component: :categories
|
||||
field :tags, component: :tags
|
||||
field :stalled_after, component: :choices, extra: { content: duration_choices }, required: true
|
||||
|
||||
placeholder :topic_url
|
||||
end
|
@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAutomation
|
||||
module Triggers
|
||||
module StalledWiki
|
||||
DURATION_CHOICES = [
|
||||
{ id: "PT1H", name: "discourse_automation.triggerables.stalled_wiki.durations.PT1H" },
|
||||
{ id: "P1D", name: "discourse_automation.triggerables.stalled_wiki.durations.P1D" },
|
||||
{ id: "P1W", name: "discourse_automation.triggerables.stalled_wiki.durations.P1W" },
|
||||
{ id: "P2W", name: "discourse_automation.triggerables.stalled_wiki.durations.P2W" },
|
||||
{ id: "P1M", name: "discourse_automation.triggerables.stalled_wiki.durations.P1M" },
|
||||
{ id: "P3M", name: "discourse_automation.triggerables.stalled_wiki.durations.P3M" },
|
||||
{ id: "P6M", name: "discourse_automation.triggerables.stalled_wiki.durations.P6M" },
|
||||
{ id: "P1Y", name: "discourse_automation.triggerables.stalled_wiki.durations.P1Y" },
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::STALLED_WIKI) do
|
||||
field :restricted_category, component: :category
|
||||
field :stalled_after,
|
||||
component: :choices,
|
||||
extra: {
|
||||
content: DiscourseAutomation::Triggers::StalledWiki::DURATION_CHOICES,
|
||||
},
|
||||
required: true
|
||||
field :retriggered_after,
|
||||
component: :choices,
|
||||
extra: {
|
||||
content: DiscourseAutomation::Triggers::StalledWiki::DURATION_CHOICES,
|
||||
}
|
||||
|
||||
placeholder :wiki_url
|
||||
|
||||
enable_manual_trigger
|
||||
end
|
@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::TOPIC) do
|
||||
field :restricted_topic, component: :text
|
||||
|
||||
on_update do |automation, metadata, previous_metadata|
|
||||
ActiveRecord::Base.transaction do
|
||||
previous_topic_id = previous_metadata.dig("restricted_topic", "value")
|
||||
topic_id = metadata.dig("restricted_topic", "value")
|
||||
|
||||
if previous_topic_id && previous_topic_id != topic_id
|
||||
previous_topic = Topic.find_by(id: previous_topic_id)
|
||||
|
||||
if previous_topic
|
||||
TopicCustomField.where(
|
||||
topic_id: previous_topic_id,
|
||||
name: DiscourseAutomation::CUSTOM_FIELD,
|
||||
value: automation.id,
|
||||
).delete_all
|
||||
end
|
||||
end
|
||||
|
||||
if topic_id
|
||||
topic = Topic.find_by(id: topic_id)
|
||||
topic&.upsert_custom_fields(DiscourseAutomation::CUSTOM_FIELD => automation.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::USER_ADDED_TO_GROUP) do
|
||||
field :joined_group, component: :group, required: true
|
||||
|
||||
placeholder :group_name
|
||||
end
|
@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::USER_BADGE_GRANTED) do
|
||||
field :badge,
|
||||
component: :choices,
|
||||
extra: {
|
||||
content: Badge.all.map { |b| { id: b.id, translated_name: b.name } },
|
||||
},
|
||||
required: true
|
||||
field :only_first_grant, component: :boolean
|
||||
placeholder :badge_name
|
||||
placeholder :grant_count
|
||||
end
|
@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::USER_FIRST_LOGGED_IN) {}
|
@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::USER_PROMOTED) do
|
||||
field :restricted_group, component: :group
|
||||
field :trust_level_transition,
|
||||
component: :choices,
|
||||
extra: {
|
||||
content: DiscourseAutomation::USER_PROMOTED_TRUST_LEVEL_CHOICES,
|
||||
},
|
||||
required: true
|
||||
|
||||
placeholder :trust_level_transition
|
||||
end
|
@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::USER_REMOVED_FROM_GROUP) do
|
||||
field :left_group, component: :group, required: true
|
||||
|
||||
placeholder :group_name
|
||||
end
|
@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class DiscourseAutomation::Triggerable
|
||||
USER_UPDATED = "user_updated"
|
||||
end
|
||||
|
||||
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::USER_UPDATED) do
|
||||
field :custom_fields, component: :custom_fields
|
||||
field :user_profile, component: :user_profile
|
||||
field :once_per_user, component: :boolean
|
||||
|
||||
validate do
|
||||
has_triggers = has_trigger_field?(:custom_fields) && has_trigger_field?(:user_profile)
|
||||
custom_fields = trigger_field(:custom_fields)["value"]
|
||||
user_profile = trigger_field(:user_profile)["value"]
|
||||
|
||||
if has_triggers && custom_fields.blank? && user_profile.blank?
|
||||
errors.add(
|
||||
:base,
|
||||
I18n.t("discourse_automation.triggerables.errors.custom_fields_or_user_profile_required"),
|
||||
)
|
||||
false
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
placeholder do |fields, automation|
|
||||
custom_fields = automation.trigger_field("custom_fields")["value"] || []
|
||||
user_profile = automation.trigger_field("user_profile")["value"] || []
|
||||
custom_fields + user_profile
|
||||
end
|
||||
end
|
Reference in New Issue
Block a user