FEATURE: Merge discourse-automation (#26432)

Automation (previously known as discourse-automation) is now a core plugin.
This commit is contained in:
Osama Sayegh
2024-04-03 18:20:43 +03:00
committed by GitHub
parent 2190c9b957
commit 3d4faf3272
314 changed files with 21182 additions and 10 deletions

View 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

View 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

View File

@ -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

View 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

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View 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

View File

@ -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

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::API_CALL) {}

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::CATEGORY_CREATED_EDITED) do
field :restricted_category, component: :category
end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
DiscourseAutomation::Triggerable.add(DiscourseAutomation::Triggers::USER_FIRST_LOGGED_IN) {}

View File

@ -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

View File

@ -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

View File

@ -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