diff --git a/.gitignore b/.gitignore index dfd3e201c92..d576b73cc0e 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,8 @@ !/plugins/discourse-narrative-bot !/plugins/discourse-presence !/plugins/discourse-lazy-videos/ +!/plugins/automation/ +/plugins/automation/gems !/plugins/chat/ !/plugins/poll/ !/plugins/styleguide diff --git a/app/models/post_custom_field.rb b/app/models/post_custom_field.rb index 91384800b96..346711fcd96 100644 --- a/app/models/post_custom_field.rb +++ b/app/models/post_custom_field.rb @@ -19,9 +19,11 @@ end # # Indexes # -# index_post_custom_fields_on_name_and_value (name, "left"(value, 200)) -# index_post_custom_fields_on_notice (post_id) UNIQUE WHERE ((name)::text = 'notice'::text) -# index_post_custom_fields_on_post_id (post_id) UNIQUE WHERE ((name)::text = 'missing uploads'::text) -# index_post_custom_fields_on_post_id_and_name (post_id,name) -# index_post_id_where_missing_uploads_ignored (post_id) UNIQUE WHERE ((name)::text = 'missing uploads ignored'::text) +# idx_post_custom_fields_discourse_automation_unique_id_partial (post_id,value) UNIQUE WHERE ((name)::text = 'discourse_automation_ids'::text) +# index_post_custom_fields_on_name_and_value (name, "left"(value, 200)) +# index_post_custom_fields_on_notice (post_id) UNIQUE WHERE ((name)::text = 'notice'::text) +# index_post_custom_fields_on_post_id (post_id) UNIQUE WHERE ((name)::text = 'missing uploads'::text) +# index_post_custom_fields_on_post_id_and_name (post_id,name) +# index_post_custom_fields_on_stalled_wiki_triggered_at (post_id) UNIQUE WHERE ((name)::text = 'stalled_wiki_triggered_at'::text) +# index_post_id_where_missing_uploads_ignored (post_id) UNIQUE WHERE ((name)::text = 'missing uploads ignored'::text) # diff --git a/app/models/topic_custom_field.rb b/app/models/topic_custom_field.rb index 6a8a799bad3..25afd75a1df 100644 --- a/app/models/topic_custom_field.rb +++ b/app/models/topic_custom_field.rb @@ -19,6 +19,8 @@ end # # Indexes # -# index_topic_custom_fields_on_topic_id_and_name (topic_id,name) -# topic_custom_fields_value_key_idx (value,name) WHERE ((value IS NOT NULL) AND (char_length(value) < 400)) +# idx_topic_custom_fields_auto_responder_triggered_ids_partial (topic_id,value) UNIQUE WHERE ((name)::text = 'auto_responder_triggered_ids'::text) +# idx_topic_custom_fields_discourse_automation_unique_id_partial (topic_id,value) UNIQUE WHERE ((name)::text = 'discourse_automation_ids'::text) +# index_topic_custom_fields_on_topic_id_and_name (topic_id,name) +# topic_custom_fields_value_key_idx (value,name) WHERE ((value IS NOT NULL) AND (char_length(value) < 400)) # diff --git a/app/models/user_custom_field.rb b/app/models/user_custom_field.rb index e4d9b4f9182..931093bf579 100644 --- a/app/models/user_custom_field.rb +++ b/app/models/user_custom_field.rb @@ -26,5 +26,6 @@ end # # Indexes # -# index_user_custom_fields_on_user_id_and_name (user_id,name) +# idx_user_custom_fields_discourse_automation_unique_id_partial (user_id,value) UNIQUE WHERE ((name)::text = 'discourse_automation_ids'::text) +# index_user_custom_fields_on_user_id_and_name (user_id,name) # diff --git a/lib/plugin/metadata.rb b/lib/plugin/metadata.rb index 5b7c9681bbd..8ee21574963 100644 --- a/lib/plugin/metadata.rb +++ b/lib/plugin/metadata.rb @@ -16,7 +16,6 @@ class Plugin::Metadata discourse-apple-auth discourse-assign discourse-auto-deactivate - discourse-automation discourse-bbcode discourse-bbcode-color discourse-bcc @@ -91,6 +90,7 @@ class Plugin::Metadata discourse-yearly-review discourse-zendesk-plugin discourse-zoom + automation docker_manager chat poll diff --git a/plugins/automation/app/controllers/discourse_automation/admin_automations_controller.rb b/plugins/automation/app/controllers/discourse_automation/admin_automations_controller.rb new file mode 100644 index 00000000000..5cf6cb0c423 --- /dev/null +++ b/plugins/automation/app/controllers/discourse_automation/admin_automations_controller.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class AdminAutomationsController < ::Admin::AdminController + requires_plugin DiscourseAutomation::PLUGIN_NAME + + def index + automations = DiscourseAutomation::Automation.order(:name).all + serializer = + ActiveModel::ArraySerializer.new( + automations, + each_serializer: DiscourseAutomation::AutomationSerializer, + root: "automations", + ).as_json + render_json_dump(serializer) + end + + def show + automation = DiscourseAutomation::Automation.find(params[:id]) + render_serialized_automation(automation) + end + + def create + automation_params = params.require(:automation).permit(:name, :script, :trigger) + + automation = + DiscourseAutomation::Automation.new( + automation_params.merge(last_updated_by_id: current_user.id), + ) + if automation.scriptable.forced_triggerable + automation.trigger = scriptable.forced_triggerable[:triggerable].to_s + end + + automation.save! + + render_serialized_automation(automation) + end + + def update + params.require(:automation) + + automation = DiscourseAutomation::Automation.find(params[:id]) + if automation.scriptable.forced_triggerable + params[:trigger] = automation.scriptable.forced_triggerable[:triggerable].to_s + end + + attributes = + request.parameters[:automation].slice(:name, :id, :script, :trigger, :enabled).merge( + last_updated_by_id: current_user.id, + ) + + if automation.trigger != params[:automation][:trigger] + params[:automation][:fields] = [] + attributes[:enabled] = false + automation.fields.destroy_all + end + + if automation.script != params[:automation][:script] + attributes[:trigger] = nil + params[:automation][:fields] = [] + attributes[:enabled] = false + automation.fields.destroy_all + automation.tap { |r| r.assign_attributes(attributes) }.save!(validate: false) + else + Array(params[:automation][:fields]) + .reject(&:empty?) + .each do |field| + automation.upsert_field!( + field[:name], + field[:component], + field[:metadata], + target: field[:target], + ) + end + + automation.tap { |r| r.assign_attributes(attributes) }.save! + end + + render_serialized_automation(automation) + end + + def destroy + automation = DiscourseAutomation::Automation.find(params[:id]) + automation.destroy! + render json: success_json + end + + private + + def render_serialized_automation(automation) + serializer = + DiscourseAutomation::AutomationSerializer.new(automation, root: "automation").as_json + render_json_dump(serializer) + end + end +end diff --git a/plugins/automation/app/controllers/discourse_automation/admin_controller.rb b/plugins/automation/app/controllers/discourse_automation/admin_controller.rb new file mode 100644 index 00000000000..aa064b88e59 --- /dev/null +++ b/plugins/automation/app/controllers/discourse_automation/admin_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class AdminController < ::Admin::AdminController + requires_plugin DiscourseAutomation::PLUGIN_NAME + + def index + end + + def new + end + + def edit + end + end +end diff --git a/plugins/automation/app/controllers/discourse_automation/admin_scriptables_controller.rb b/plugins/automation/app/controllers/discourse_automation/admin_scriptables_controller.rb new file mode 100644 index 00000000000..83d6fe46801 --- /dev/null +++ b/plugins/automation/app/controllers/discourse_automation/admin_scriptables_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class AdminScriptablesController < ::Admin::AdminController + requires_plugin DiscourseAutomation::PLUGIN_NAME + + def index + scriptables = + DiscourseAutomation::Scriptable.all.map do |s| + id = s.to_s.gsub(/^__scriptable_/, "") + { + id: id, + name: I18n.t("discourse_automation.scriptables.#{id}.title"), + description: I18n.t("discourse_automation.scriptables.#{id}.description", default: ""), + doc: I18n.t("discourse_automation.scriptables.#{id}.doc", default: ""), + } + end + + scriptables.sort_by! { |s| s[:name] } + render_json_dump(scriptables: scriptables) + end + end +end diff --git a/plugins/automation/app/controllers/discourse_automation/admin_triggerables_controller.rb b/plugins/automation/app/controllers/discourse_automation/admin_triggerables_controller.rb new file mode 100644 index 00000000000..3c6be2891e9 --- /dev/null +++ b/plugins/automation/app/controllers/discourse_automation/admin_triggerables_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class AdminTriggerablesController < ::Admin::AdminController + requires_plugin DiscourseAutomation::PLUGIN_NAME + + def index + if params[:automation_id].present? + automation = DiscourseAutomation::Automation.find(params[:automation_id]) + scriptable = automation.scriptable + triggerables = scriptable.triggerables + else + triggerables = DiscourseAutomation::Triggerable.all + end + + triggerables = + triggerables.map do |s| + id = s.to_s.gsub(/^__triggerable_/, "") + { + id: id, + name: I18n.t("discourse_automation.triggerables.#{id}.title"), + description: I18n.t("discourse_automation.triggerables.#{id}.description", default: ""), + doc: I18n.t("discourse_automation.triggerables.#{id}.doc", default: ""), + } + end + + render_json_dump(triggerables: triggerables) + end + end +end diff --git a/plugins/automation/app/controllers/discourse_automation/append_last_checked_by_controller.rb b/plugins/automation/app/controllers/discourse_automation/append_last_checked_by_controller.rb new file mode 100644 index 00000000000..b2c9c727f58 --- /dev/null +++ b/plugins/automation/app/controllers/discourse_automation/append_last_checked_by_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class AppendLastCheckedByController < ApplicationController + requires_plugin DiscourseAutomation::PLUGIN_NAME + requires_login + + def post_checked + post = Post.find(params[:post_id]) + guardian.ensure_can_edit!(post) + + topic = post.topic + raise Discourse::NotFound if topic.blank? + + topic.custom_fields[DiscourseAutomation::TOPIC_LAST_CHECKED_BY] = current_user.username + topic.custom_fields[DiscourseAutomation::TOPIC_LAST_CHECKED_AT] = Time.zone.now.to_s + topic.save_custom_fields + + post.rebake! + + render json: success_json + end + end +end diff --git a/plugins/automation/app/controllers/discourse_automation/automations_controller.rb b/plugins/automation/app/controllers/discourse_automation/automations_controller.rb new file mode 100644 index 00000000000..eb0147838e9 --- /dev/null +++ b/plugins/automation/app/controllers/discourse_automation/automations_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class AutomationsController < ApplicationController + requires_plugin DiscourseAutomation::PLUGIN_NAME + before_action :ensure_admin + + def trigger + automation = DiscourseAutomation::Automation.find(params[:id]) + automation.trigger_in_background!(params.merge(kind: DiscourseAutomation::Triggers::API_CALL)) + render json: success_json + end + end +end diff --git a/plugins/automation/app/controllers/discourse_automation/user_global_notices_controller.rb b/plugins/automation/app/controllers/discourse_automation/user_global_notices_controller.rb new file mode 100644 index 00000000000..edadec9e4a0 --- /dev/null +++ b/plugins/automation/app/controllers/discourse_automation/user_global_notices_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class UserGlobalNoticesController < ApplicationController + requires_plugin DiscourseAutomation::PLUGIN_NAME + requires_login + + def destroy + notice = + DiscourseAutomation::UserGlobalNotice.find_by(user_id: current_user.id, id: params[:id]) + + raise Discourse::NotFound unless notice + + notice.destroy! + + head :no_content + end + end +end diff --git a/plugins/automation/app/jobs/regular/discourse_automation_call_zapier_webhook.rb b/plugins/automation/app/jobs/regular/discourse_automation_call_zapier_webhook.rb new file mode 100644 index 00000000000..30180f7dbc1 --- /dev/null +++ b/plugins/automation/app/jobs/regular/discourse_automation_call_zapier_webhook.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Jobs + class DiscourseAutomationCallZapierWebhook < ::Jobs::Base + def execute(args) + RateLimiter.new(nil, "discourse_automation_call_zapier", 5, 30).performed! + + result = + Excon.post( + args["webhook_url"], + body: args["context"].to_json, + headers: { + "Content-Type" => "application/json", + "Accept" => "application/json", + }, + ) + + if result.status != 200 + Rails.logger.warn( + "Failed to call Zapier webhook at #{args["webhook_url"]} Status: #{result.status}: #{result.status_line}", + ) + end + end + end +end diff --git a/plugins/automation/app/jobs/regular/discourse_automation_trigger.rb b/plugins/automation/app/jobs/regular/discourse_automation_trigger.rb new file mode 100644 index 00000000000..ad1134acdb1 --- /dev/null +++ b/plugins/automation/app/jobs/regular/discourse_automation_trigger.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Jobs + class DiscourseAutomationTrigger < ::Jobs::Base + RETRY_TIMES = [5.minute, 15.minute, 120.minute] + + sidekiq_options retry: RETRY_TIMES.size + + sidekiq_retry_in do |count, exception| + # returning nil/0 will trigger the default sidekiq + # retry formula + # + # See https://github.com/mperham/sidekiq/blob/3330df0ee37cfd3e0cd3ef01e3e66b584b99d488/lib/sidekiq/job_retry.rb#L216-L234 + case exception.wrapped + when SocketError + return RETRY_TIMES[count] + end + end + + def execute(args) + automation = DiscourseAutomation::Automation.find_by(id: args[:automation_id], enabled: true) + + return if !automation + + context = DiscourseAutomation::Automation.deserialize_context(args[:context]) + + automation.running_in_background! + automation.trigger!(context) + end + end +end diff --git a/plugins/automation/app/jobs/scheduled/discourse_automation_tracker.rb b/plugins/automation/app/jobs/scheduled/discourse_automation_tracker.rb new file mode 100644 index 00000000000..2e8f60542c6 --- /dev/null +++ b/plugins/automation/app/jobs/scheduled/discourse_automation_tracker.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Jobs + class DiscourseAutomationTracker < ::Jobs::Scheduled + every 1.minute + + BATCH_LIMIT ||= 300 + + def execute(_args = nil) + return unless SiteSetting.discourse_automation_enabled + + DiscourseAutomation::PendingAutomation + .includes(:automation) + .limit(BATCH_LIMIT) + .where("execute_at < ?", Time.now) + .find_each { |pending_automation| run_pending_automation(pending_automation) } + + DiscourseAutomation::PendingPm + .includes(:automation) + .limit(BATCH_LIMIT) + .where("execute_at < ?", Time.now) + .find_each { |pending_pm| send_pending_pm(pending_pm) } + end + + def send_pending_pm(pending_pm) + DiscourseAutomation::Scriptable::Utils.send_pm( + pending_pm.attributes.slice("target_usernames", "title", "raw"), + sender: pending_pm.sender, + prefers_encrypt: pending_pm.prefers_encrypt, + ) + + pending_pm.destroy! + end + + def run_pending_automation(pending_automation) + pending_automation.automation.trigger!( + "kind" => pending_automation.automation.trigger, + "execute_at" => pending_automation.execute_at, + ) + + pending_automation.destroy! + end + end +end diff --git a/plugins/automation/app/jobs/scheduled/stalled_topic_tracker.rb b/plugins/automation/app/jobs/scheduled/stalled_topic_tracker.rb new file mode 100644 index 00000000000..558fcde2d31 --- /dev/null +++ b/plugins/automation/app/jobs/scheduled/stalled_topic_tracker.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Jobs + class StalledTopicTracker < ::Jobs::Scheduled + every 1.hour + + def execute(_args = nil) + name = DiscourseAutomation::Triggers::STALLED_TOPIC + + DiscourseAutomation::Automation + .where(trigger: name, enabled: true) + .find_each do |automation| + fields = automation.serialized_fields + stalled_after = fields.dig("stalled_after", "value") + stalled_duration = ISO8601::Duration.new(stalled_after).to_seconds + stalled_date = stalled_duration.seconds.ago + categories = fields.dig("categories", "value") + tags = fields.dig("tags", "value") + + StalledTopicFinder + .call(stalled_date, categories: categories, tags: tags) + .each do |result| + topic = Topic.find_by(id: result.id) + next unless topic + + run_trigger(automation, topic) + end + end + end + + def run_trigger(automation, topic) + automation.trigger!( + "kind" => DiscourseAutomation::Triggers::STALLED_TOPIC, + "topic" => topic, + "placeholders" => { + "topic_url" => topic.url, + }, + ) + end + end +end diff --git a/plugins/automation/app/jobs/scheduled/stalled_wiki_tracker.rb b/plugins/automation/app/jobs/scheduled/stalled_wiki_tracker.rb new file mode 100644 index 00000000000..58dc17caa59 --- /dev/null +++ b/plugins/automation/app/jobs/scheduled/stalled_wiki_tracker.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Jobs + class StalledWikiTracker < ::Jobs::Scheduled + every 10.minutes + + def execute(_args = nil) + name = DiscourseAutomation::Triggers::STALLED_WIKI + + DiscourseAutomation::Automation + .where(trigger: name, enabled: true) + .find_each do |automation| + stalled_after = automation.trigger_field("stalled_after") + stalled_duration = ISO8601::Duration.new(stalled_after["value"]).to_seconds + finder = Post.where("wiki = TRUE AND last_version_at <= ?", stalled_duration.seconds.ago) + + restricted_category = automation.trigger_field("restricted_category") + if restricted_category["value"] + finder = + finder.joins(:topic).where("topics.category_id = ?", restricted_category["value"]) + end + + finder.each do |post| + last_trigger_date = post.custom_fields["stalled_wiki_triggered_at"] + if last_trigger_date + retriggered_after = automation.trigger_field("retriggered_after") + retrigger_duration = ISO8601::Duration.new(retriggered_after["value"]).to_seconds + + next if Time.parse(last_trigger_date) + retrigger_duration >= Time.zone.now + end + + post.upsert_custom_fields(stalled_wiki_triggered_at: Time.zone.now) + run_trigger(automation, post) + end + end + end + + def run_trigger(automation, post) + user_ids = + ( + post.post_revisions.order("post_revisions.created_at DESC").limit(5).pluck(:user_id) + + [post.user_id] + ).compact.uniq + + automation.trigger!( + "kind" => DiscourseAutomation::Triggers::STALLED_WIKI, + "post" => post, + "topic" => post.topic, + "usernames" => User.where(id: user_ids).pluck(:username), + "placeholders" => { + "wiki_url" => Discourse.base_url + post.url, + }, + ) + end + end +end diff --git a/plugins/automation/app/models/discourse_automation/automation.rb b/plugins/automation/app/models/discourse_automation/automation.rb new file mode 100644 index 00000000000..15b7b0ccc0f --- /dev/null +++ b/plugins/automation/app/models/discourse_automation/automation.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class Automation < ActiveRecord::Base + self.table_name = "discourse_automation_automations" + + has_many :fields, + class_name: "DiscourseAutomation::Field", + dependent: :delete_all, + foreign_key: "automation_id" + has_many :pending_automations, + class_name: "DiscourseAutomation::PendingAutomation", + dependent: :delete_all, + foreign_key: "automation_id" + has_many :pending_pms, + class_name: "DiscourseAutomation::PendingPm", + dependent: :delete_all, + foreign_key: "automation_id" + + validates :script, presence: true + validate :validate_trigger_fields + + attr_accessor :running_in_background + + def running_in_background! + @running_in_background = true + end + + MIN_NAME_LENGTH = 5 + MAX_NAME_LENGTH = 30 + validates :name, length: { in: MIN_NAME_LENGTH..MAX_NAME_LENGTH } + + def attach_custom_field(target) + if ![Topic, Post, User].any? { |m| target.is_a?(m) } + raise "Expected an instance of Topic/Post/User." + end + + now = Time.now + fk = target.custom_fields_fk + row = { + fk => target.id, + :name => DiscourseAutomation::CUSTOM_FIELD, + :value => id, + :created_at => now, + :updated_at => now, + } + + relation = "#{target.class.name}CustomField".constantize + relation.upsert( + row, + unique_by: + "idx_#{target.class.name.downcase}_custom_fields_discourse_automation_unique_id_partial", + ) + end + + def detach_custom_field(target) + if ![Topic, Post, User].any? { |m| target.is_a?(m) } + raise "Expected an instance of Topic/Post/User." + end + + fk = target.custom_fields_fk + relation = "#{target.class.name}CustomField".constantize + relation.where( + fk => target.id, + :name => DiscourseAutomation::CUSTOM_FIELD, + :value => id, + ).delete_all + end + + def trigger_field(name) + field = fields.find_by(target: "trigger", name: name) + field ? field.metadata : {} + end + + def has_trigger_field?(name) + !!fields.find_by(target: "trigger", name: name) + end + + def script_field(name) + field = fields.find_by(target: "script", name: name) + field ? field.metadata : {} + end + + def upsert_field!(name, component, metadata, target: "script") + field = fields.find_or_initialize_by(name: name, component: component, target: target) + field.update!(metadata: metadata) + end + + def self.deserialize_context(context) + new_context = ActiveSupport::HashWithIndifferentAccess.new + + context.each do |key, value| + if key.start_with?("_serialized_") + new_key = key[12..-1] + found = nil + if value["class"] == "Symbol" + found = value["value"].to_sym + else + found = value["class"].constantize.find_by(id: value["id"]) + end + new_context[new_key] = found + else + new_context[key] = value + end + end + new_context + end + + def self.serialize_context(context) + new_context = {} + context.each do |k, v| + if v.is_a?(Symbol) + new_context["_serialized_#{k}"] = { "class" => "Symbol", "value" => v.to_s } + elsif v.is_a?(ActiveRecord::Base) + new_context["_serialized_#{k}"] = { "class" => v.class.name, "id" => v.id } + else + new_context[k] = v + end + end + new_context + end + + def trigger_in_background!(context = {}) + Jobs.enqueue( + :discourse_automation_trigger, + automation_id: id, + context: self.class.serialize_context(context), + ) + end + + def trigger!(context = {}) + if enabled + if scriptable.background && !running_in_background + trigger_in_background!(context) + else + triggerable&.on_call&.call(self, serialized_fields) + scriptable.script.call(context, serialized_fields, self) + end + end + end + + def triggerable + trigger && @triggerable ||= DiscourseAutomation::Triggerable.new(trigger, self) + end + + def scriptable + script && @scriptable ||= DiscourseAutomation::Scriptable.new(script, self) + end + + def serialized_fields + fields + &.pluck(:name, :metadata) + &.reduce({}) do |acc, hash| + name, field = hash + acc[name] = field + acc + end || {} + end + + def reset! + pending_automations.delete_all + pending_pms.delete_all + scriptable&.on_reset&.call(self) + end + + private + + def validate_trigger_fields + !triggerable || triggerable.valid?(self) + end + end +end diff --git a/plugins/automation/app/models/discourse_automation/field.rb b/plugins/automation/app/models/discourse_automation/field.rb new file mode 100644 index 00000000000..567598fc251 --- /dev/null +++ b/plugins/automation/app/models/discourse_automation/field.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class Field < ActiveRecord::Base + self.table_name = "discourse_automation_fields" + + belongs_to :automation, class_name: "DiscourseAutomation::Automation" + + around_save :on_update_callback + + def on_update_callback + previous_fields = automation.serialized_fields + + automation.reset! + + yield + + automation&.triggerable&.on_update&.call( + automation, + automation.serialized_fields, + previous_fields, + ) + end + + validate :required_field + def required_field + if template && template[:required] && metadata && metadata["value"].blank? + raise_required_field(name, target, targetable) + end + end + + validate :validator + def validator + if template && template[:validator] + error = template[:validator].call(metadata["value"]) + errors.add(:base, error) if error + end + end + + def targetable + target == "trigger" ? automation.triggerable : automation.scriptable + end + + def template + targetable&.fields&.find do |tf| + targetable.id == target && tf[:name].to_s == name && tf[:component].to_s == component + end + end + + validate :metadata_schema + def metadata_schema + if !(targetable.components.include?(component.to_sym)) + errors.add( + :base, + I18n.t( + "discourse_automation.models.fields.invalid_field", + component: component, + target: target, + target_name: targetable.name, + ), + ) + else + schema = SCHEMAS[component] + if !schema || + !JSONSchemer.schema({ "type" => "object", "properties" => schema }).valid?(metadata) + errors.add( + :base, + I18n.t( + "discourse_automation.models.fields.invalid_metadata", + component: component, + field: name, + ), + ) + end + end + end + + SCHEMAS = { + "key-value" => { + "type" => "array", + "uniqueItems" => true, + "items" => { + "type" => "object", + "title" => "group", + "properties" => { + "key" => { + "type" => "string", + }, + "value" => { + "type" => "string", + }, + }, + }, + }, + "choices" => { + "value" => { + "type" => %w[string integer null], + }, + }, + "tags" => { + "value" => { + "type" => "array", + "items" => [{ type: "string" }], + }, + }, + "trust-levels" => { + "value" => { + "type" => "array", + "items" => [{ type: "integer" }], + }, + }, + "categories" => { + "value" => { + "type" => "array", + "items" => [{ type: "string" }], + }, + }, + "category" => { + "value" => { + "type" => %w[string integer null], + }, + }, + "category_notification_level" => { + "value" => { + "type" => "integer", + }, + }, + "custom_field" => { + "value" => { + "type" => "integer", + }, + }, + "custom_fields" => { + "value" => { + "type" => [{ type: "string" }], + }, + }, + "user" => { + "value" => { + "type" => "string", + }, + }, + "user_profile" => { + "value" => { + "type" => "array", + "items" => [{ type: "string" }], + }, + }, + "users" => { + "value" => { + "type" => "array", + "items" => [{ type: "string" }], + }, + }, + "text" => { + "value" => { + "type" => %w[string integer null], + }, + }, + "post" => { + "value" => { + "type" => %w[string integer null], + }, + }, + "message" => { + "value" => { + "type" => %w[string integer null], + }, + }, + "boolean" => { + "value" => { + "type" => ["boolean"], + }, + }, + "text_list" => { + "value" => { + "type" => "array", + "items" => [{ type: "string" }], + }, + }, + "date_time" => { + "value" => { + "type" => "string", + }, + }, + "group" => { + "value" => { + "type" => "integer", + }, + }, + "email_group_user" => { + "value" => { + "type" => "array", + "items" => [{ type: "string" }], + }, + }, + "pms" => { + type: "array", + items: [ + { + type: "object", + properties: { + "raw" => { + "type" => "string", + }, + "title" => { + "type" => "string", + }, + "delay" => { + "type" => "integer", + }, + "prefers_encrypt" => { + "type" => "boolean", + }, + }, + }, + ], + }, + "period" => { + "type" => "object", + "properties" => { + "interval" => { + "type" => "integer", + }, + "frequency" => { + "type" => "string", + }, + }, + }, + } + + private + + def raise_required_field(name, target, targetable) + errors.add( + :base, + I18n.t( + "discourse_automation.models.fields.required_field", + name: name, + target: target, + target_name: targetable.name, + ), + ) + end + end +end diff --git a/plugins/automation/app/models/discourse_automation/pending_automation.rb b/plugins/automation/app/models/discourse_automation/pending_automation.rb new file mode 100644 index 00000000000..f6c4e0e6e65 --- /dev/null +++ b/plugins/automation/app/models/discourse_automation/pending_automation.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class PendingAutomation < ActiveRecord::Base + self.table_name = "discourse_automation_pending_automations" + + belongs_to :automation, class_name: "DiscourseAutomation::Automation" + end +end diff --git a/plugins/automation/app/models/discourse_automation/pending_pm.rb b/plugins/automation/app/models/discourse_automation/pending_pm.rb new file mode 100644 index 00000000000..b696ee09c27 --- /dev/null +++ b/plugins/automation/app/models/discourse_automation/pending_pm.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class PendingPm < ActiveRecord::Base + self.table_name = "discourse_automation_pending_pms" + + belongs_to :automation, class_name: "DiscourseAutomation::Automation" + end +end diff --git a/plugins/automation/app/models/discourse_automation/user_global_notice.rb b/plugins/automation/app/models/discourse_automation/user_global_notice.rb new file mode 100644 index 00000000000..d41d4643592 --- /dev/null +++ b/plugins/automation/app/models/discourse_automation/user_global_notice.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class UserGlobalNotice < ActiveRecord::Base + self.table_name = "discourse_automation_user_global_notices" + + belongs_to :user + end +end diff --git a/plugins/automation/app/queries/stalled_topic_finder.rb b/plugins/automation/app/queries/stalled_topic_finder.rb new file mode 100644 index 00000000000..e4e59fdd6d4 --- /dev/null +++ b/plugins/automation/app/queries/stalled_topic_finder.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class StalledTopicFinder + def self.call(stalled_date, tags: nil, categories: nil) + sql = <<~SQL + SELECT t.id + FROM topics t + SQL + + sql += <<~SQL if tags + JOIN topic_tags ON topic_tags.topic_id = t.id + JOIN tags + ON tags.name IN (:tags) + AND tags.id = topic_tags.tag_id + SQL + + sql += <<~SQL + WHERE t.deleted_at IS NULL + AND t.posts_count > 0 + AND t.archetype != 'private_message' + AND NOT t.closed + AND NOT t.archived + AND NOT EXISTS ( + SELECT p.id + FROM posts p + WHERE t.id = p.topic_id + AND p.deleted_at IS NULL + AND t.user_id = p.user_id + AND p.created_at > :stalled_date + LIMIT 1 + ) + SQL + + sql += <<~SQL if categories + AND t.category_id IN (:categories) + SQL + + sql += <<~SQL + LIMIT 250 + SQL + + DB.query(sql, categories: categories, tags: tags, stalled_date: stalled_date) + end +end diff --git a/plugins/automation/app/serializers/discourse_automation/automation_serializer.rb b/plugins/automation/app/serializers/discourse_automation/automation_serializer.rb new file mode 100644 index 00000000000..b5ca7079447 --- /dev/null +++ b/plugins/automation/app/serializers/discourse_automation/automation_serializer.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class AutomationSerializer < ApplicationSerializer + attributes :id + attributes :name + attributes :enabled + attributes :script + attributes :trigger + attributes :updated_at + attributes :last_updated_by + attributes :next_pending_automation_at + attributes :placeholders + + def last_updated_by + BasicUserSerializer.new( + User.find_by(id: object.last_updated_by_id) || Discourse.system_user, + root: false, + ).as_json + end + + def include_next_pending_automation_at? + object.pending_automations.exists? + end + + def next_pending_automation_at + object&.pending_automations&.first&.execute_at + end + + def placeholders + scriptable_placeholders = + DiscourseAutomation + .filter_by_trigger(scriptable&.placeholders || [], object.trigger) + .map { |placeholder| placeholder[:name] } + triggerable_placeholders = triggerable&.placeholders || [] + + (scriptable_placeholders + triggerable_placeholders).map do |placeholder| + placeholder.to_s.gsub(/\s+/, "_").underscore + end + end + + def script + key = "discourse_automation.scriptables" + doc_key = "#{key}.#{object.script}.doc" + script_with_trigger_key = "#{key}.#{object.script}_with_#{object.trigger}.doc" + + { + id: object.script, + version: scriptable.version, + name: I18n.t("#{key}.#{object.script}.title"), + description: I18n.t("#{key}.#{object.script}.description"), + doc: I18n.exists?(doc_key, :en) ? I18n.t(doc_key) : nil, + with_trigger_doc: + I18n.exists?(script_with_trigger_key, :en) ? I18n.t(script_with_trigger_key) : nil, + forced_triggerable: scriptable.forced_triggerable, + not_found: scriptable.not_found, + templates: + process_templates(filter_fields_with_priority(scriptable.fields, object.trigger&.to_sym)), + fields: process_fields(object.fields.where(target: "script")), + } + end + + def trigger + key = "discourse_automation.triggerables" + doc_key = "#{key}.#{object.trigger}.doc" + + { + id: object.trigger, + name: I18n.t("#{key}.#{object.trigger}.title"), + description: I18n.t("#{key}.#{object.trigger}.description"), + doc: I18n.exists?(doc_key, :en) ? I18n.t(doc_key) : nil, + not_found: triggerable&.not_found, + templates: process_templates(triggerable&.fields || []), + fields: process_fields(object.fields.where(target: "trigger")), + settings: triggerable&.settings, + } + end + + private + + def filter_fields_with_priority(arr, trigger) + unique_with_priority = {} + + arr.each do |item| + name = item[:name] + if (item[:triggerable]&.to_sym == trigger&.to_sym || item[:triggerable].nil?) && + (!unique_with_priority.key?(name) || unique_with_priority[name][:triggerable].nil?) + unique_with_priority[name] = item + end + end + + unique_with_priority.values + end + + def process_templates(fields) + ActiveModel::ArraySerializer.new( + fields, + each_serializer: DiscourseAutomation::TemplateSerializer, + scope: { + automation: object, + }, + ).as_json + end + + def process_fields(fields) + ActiveModel::ArraySerializer.new( + fields || [], + each_serializer: DiscourseAutomation::FieldSerializer, + ).as_json || [] + end + + def scriptable + object.scriptable + end + + def triggerable + object.triggerable + end + end +end diff --git a/plugins/automation/app/serializers/discourse_automation/field_serializer.rb b/plugins/automation/app/serializers/discourse_automation/field_serializer.rb new file mode 100644 index 00000000000..d3e630562a2 --- /dev/null +++ b/plugins/automation/app/serializers/discourse_automation/field_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class FieldSerializer < ApplicationSerializer + attributes :id, :component, :name, :metadata, :is_required + + def metadata + object.metadata || {} + end + + def is_required + object.template&.dig(:required) + end + end +end diff --git a/plugins/automation/app/serializers/discourse_automation/template_serializer.rb b/plugins/automation/app/serializers/discourse_automation/template_serializer.rb new file mode 100644 index 00000000000..aafb13a4935 --- /dev/null +++ b/plugins/automation/app/serializers/discourse_automation/template_serializer.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class TemplateSerializer < ApplicationSerializer + attributes :name, + :component, + :extra, + :accepts_placeholders, + :accepted_contexts, + :read_only, + :default_value, + :is_required + + def default_value + scope[:automation].scriptable&.forced_triggerable&.dig(:state, name) || object[:default_value] + end + + def read_only + scope[:automation].scriptable&.forced_triggerable&.dig(:state, name).present? + end + + def name + object[:name] + end + + def component + object[:component] + end + + def extra + object[:extra] + end + + def accepts_placeholders + object[:accepts_placeholders] + end + + def accepted_contexts + object[:accepted_contexts] + end + + def is_required + object[:required] + end + end +end diff --git a/plugins/automation/app/serializers/discourse_automation/trigger_serializer.rb b/plugins/automation/app/serializers/discourse_automation/trigger_serializer.rb new file mode 100644 index 00000000000..2c7edb3b6bd --- /dev/null +++ b/plugins/automation/app/serializers/discourse_automation/trigger_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class TriggerSerializer < ApplicationSerializer + attributes :id, :name, :metadata + + def metadata + ((options[:trigger_metadata] || {}).stringify_keys).merge(object.metadata || {}) + end + end +end diff --git a/plugins/automation/app/serializers/discourse_automation/user_global_notice_serializer.rb b/plugins/automation/app/serializers/discourse_automation/user_global_notice_serializer.rb new file mode 100644 index 00000000000..8391d6fdc21 --- /dev/null +++ b/plugins/automation/app/serializers/discourse_automation/user_global_notice_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class UserGlobalNoticeSerializer < ApplicationSerializer + attributes :id, :notice, :level, :created_at, :updated_at, :identifier + + def level + object.level || "info" + end + end +end diff --git a/plugins/automation/app/services/discourse_automation/user_badge_granted_handler.rb b/plugins/automation/app/services/discourse_automation/user_badge_granted_handler.rb new file mode 100644 index 00000000000..ff20f6a49b7 --- /dev/null +++ b/plugins/automation/app/services/discourse_automation/user_badge_granted_handler.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module DiscourseAutomation + class UserBadgeGrantedHandler + def self.handle(automation, badge_id, user_id) + tracked_badge_id = automation.trigger_field("badge")["value"] + return if tracked_badge_id != badge_id + + badge = Badge.find(badge_id) + + only_first_grant = automation.trigger_field("only_first_grant")["value"] + + return if only_first_grant && UserBadge.where(user_id: user_id, badge_id: badge_id).count > 1 + + user = User.find(user_id) + + automation.trigger!( + "kind" => DiscourseAutomation::Triggers::USER_BADGE_GRANTED, + "usernames" => [user.username], + "badge" => badge, + "placeholders" => { + "badge_name" => badge.name, + "grant_count" => badge.grant_count, + }, + ) + end + end +end diff --git a/plugins/automation/assets/javascripts/discourse/admin/adapters/discourse-automation-adapter.js b/plugins/automation/assets/javascripts/discourse/admin/adapters/discourse-automation-adapter.js new file mode 100644 index 00000000000..b8d302bab3a --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/admin/adapters/discourse-automation-adapter.js @@ -0,0 +1,11 @@ +import RestAdapter from "discourse/adapters/rest"; + +export default class Adapter extends RestAdapter { + basePath() { + return "/admin/plugins/discourse-automation/"; + } + + pathFor() { + return super.pathFor(...arguments).replace("_", "-") + ".json"; + } +} diff --git a/plugins/automation/assets/javascripts/discourse/admin/adapters/discourse-automation-automation.js b/plugins/automation/assets/javascripts/discourse/admin/adapters/discourse-automation-automation.js new file mode 100644 index 00000000000..ececd1ffc0d --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/admin/adapters/discourse-automation-automation.js @@ -0,0 +1,9 @@ +import DiscourseAutomationAdapter from "./discourse-automation-adapter"; + +export default class AutomationAdapter extends DiscourseAutomationAdapter { + jsonMode = true; + + apiNameFor() { + return "automation"; + } +} diff --git a/plugins/automation/assets/javascripts/discourse/admin/adapters/discourse-automation-scriptable.js b/plugins/automation/assets/javascripts/discourse/admin/adapters/discourse-automation-scriptable.js new file mode 100644 index 00000000000..aa9eb205e96 --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/admin/adapters/discourse-automation-scriptable.js @@ -0,0 +1,9 @@ +import DiscourseAutomationAdapter from "./discourse-automation-adapter"; + +export default class ScriptableAdapter extends DiscourseAutomationAdapter { + jsonMode = true; + + apiNameFor() { + return "scriptable"; + } +} diff --git a/plugins/automation/assets/javascripts/discourse/admin/adapters/discourse-automation-triggerable.js b/plugins/automation/assets/javascripts/discourse/admin/adapters/discourse-automation-triggerable.js new file mode 100644 index 00000000000..3fdf4b39d78 --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/admin/adapters/discourse-automation-triggerable.js @@ -0,0 +1,9 @@ +import DiscourseAutomationAdapter from "./discourse-automation-adapter"; + +export default class TriggerableAdapter extends DiscourseAutomationAdapter { + jsonMode = true; + + apiNameFor() { + return "triggerable"; + } +} diff --git a/plugins/automation/assets/javascripts/discourse/admin/models/discourse-automation-automation.js b/plugins/automation/assets/javascripts/discourse/admin/models/discourse-automation-automation.js new file mode 100644 index 00000000000..29fe6530a40 --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/admin/models/discourse-automation-automation.js @@ -0,0 +1,23 @@ +import RestModel from "discourse/models/rest"; + +const ATTRIBUTES = ["name", "script", "fields", "trigger", "id"]; + +export default class Automation extends RestModel { + updateProperties() { + return { + id: this.id, + name: this.name, + fields: this.fields, + script: this.script.id, + trigger: { + id: this.trigger.id, + name: this.trigger.name, + metadata: this.trigger.metadata, + }, + }; + } + + createProperties() { + return this.getProperties(ATTRIBUTES); + } +} diff --git a/plugins/automation/assets/javascripts/discourse/admin/models/discourse-automation-field.js b/plugins/automation/assets/javascripts/discourse/admin/models/discourse-automation-field.js new file mode 100644 index 00000000000..72c2e3f38bf --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/admin/models/discourse-automation-field.js @@ -0,0 +1,52 @@ +import { tracked } from "@glimmer/tracking"; +import { TrackedObject } from "@ember-compat/tracked-built-ins"; + +export default class DiscourseAutomationField { + static create(template, target, json = {}) { + const field = new DiscourseAutomationField(); + field.acceptsPlaceholders = template.accepts_placeholders; + field.acceptedContexts = template.accepted_contexts; + field.targetName = target.name; + field.targetType = target.type; + field.name = template.name; + field.component = template.component; + field.isDisabled = template.read_only; + + // backwards compatibility with forced scriptable fields + if (field.isDisabled) { + field.metadata.value = + template.default_value || template.value || json?.metadata?.value; + } else { + field.metadata.value = + template.value || json?.metadata?.value || template.default_value; + } + + // null is not a valid value for metadata.value + if (field.metadata.value === null) { + field.metadata.value = undefined; + } + + field.isRequired = template.is_required; + field.extra = new TrackedObject(template.extra); + return field; + } + + @tracked acceptsPlaceholders = false; + @tracked component; + @tracked extra = new TrackedObject(); + @tracked isDisabled = false; + @tracked isRequired = false; + @tracked metadata = new TrackedObject(); + @tracked name; + @tracked targetType; + @tracked targetName; + + toJSON() { + return { + name: this.name, + target: this.targetType, + component: this.component, + metadata: this.metadata, + }; + } +} diff --git a/plugins/automation/assets/javascripts/discourse/admin/models/discourse-automation-script.js b/plugins/automation/assets/javascripts/discourse/admin/models/discourse-automation-script.js new file mode 100644 index 00000000000..67079776760 --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/admin/models/discourse-automation-script.js @@ -0,0 +1,3 @@ +import RestModel from "discourse/models/rest"; + +export default class Script extends RestModel {} diff --git a/plugins/automation/assets/javascripts/discourse/components/automation-field.gjs b/plugins/automation/assets/javascripts/discourse/components/automation-field.gjs new file mode 100644 index 00000000000..eaf2e8bbe05 --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/automation-field.gjs @@ -0,0 +1,97 @@ +import Component from "@glimmer/component"; +import I18n from "I18n"; +import DaBooleanField from "./fields/da-boolean-field"; +import DaCategoriesField from "./fields/da-categories-field"; +import DaCategoryField from "./fields/da-category-field"; +import DaCategoryNotificationlevelField from "./fields/da-category-notification-level-field"; +import DaChoicesField from "./fields/da-choices-field"; +import DaCustomField from "./fields/da-custom-field"; +import DaCustomFields from "./fields/da-custom-fields"; +import DaDateTimeField from "./fields/da-date-time-field"; +import DaEmailGroupUserField from "./fields/da-email-group-user-field"; +import DaGroupField from "./fields/da-group-field"; +import DaKeyValueField from "./fields/da-key-value-field"; +import DaMessageField from "./fields/da-message-field"; +import DaPeriodField from "./fields/da-period-field"; +import DaPmsField from "./fields/da-pms-field"; +import DaPostField from "./fields/da-post-field"; +import DaTagsField from "./fields/da-tags-field"; +import DaTextField from "./fields/da-text-field"; +import DaTextListField from "./fields/da-text-list-field"; +import DaTrustLevelsField from "./fields/da-trust-levels-field"; +import DaUserField from "./fields/da-user-field"; +import DaUserProfileField from "./fields/da-user-profile-field"; +import DaUsersField from "./fields/da-users-field"; + +const FIELD_COMPONENTS = { + period: DaPeriodField, + date_time: DaDateTimeField, + text_list: DaTextListField, + pms: DaPmsField, + text: DaTextField, + message: DaMessageField, + categories: DaCategoriesField, + user: DaUserField, + users: DaUsersField, + user_profile: DaUserProfileField, + post: DaPostField, + tags: DaTagsField, + "key-value": DaKeyValueField, + boolean: DaBooleanField, + "trust-levels": DaTrustLevelsField, + category: DaCategoryField, + group: DaGroupField, + choices: DaChoicesField, + category_notification_level: DaCategoryNotificationlevelField, + email_group_user: DaEmailGroupUserField, + custom_field: DaCustomField, + custom_fields: DaCustomFields, +}; + +export default class AutomationField extends Component { + + + get component() { + return FIELD_COMPONENTS[this.args.field.component]; + } + + get label() { + return I18n.t( + `discourse_automation${this.target}fields.${this.args.field.name}.label` + ); + } + + get displayField() { + const triggerId = this.args.automation?.trigger?.id; + const triggerable = this.args.field?.triggerable; + return triggerId && (!triggerable || triggerable === triggerId); + } + + get placeholdersString() { + return this.args.field.placeholders.join(", "); + } + + get target() { + return this.args.field.targetType === "script" + ? `.scriptables.${this.args.automation.script.id.replace(/-/g, "_")}.` + : `.triggerables.${this.args.automation.trigger.id.replace(/-/g, "_")}.`; + } + + get translationKey() { + return `discourse_automation${this.target}fields.${this.args.field.name}.description`; + } + + get description() { + return I18n.lookup(this.translationKey); + } +} diff --git a/plugins/automation/assets/javascripts/discourse/components/fields/da-base-field.js b/plugins/automation/assets/javascripts/discourse/components/fields/da-base-field.js new file mode 100644 index 00000000000..3c0d4475f79 --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/fields/da-base-field.js @@ -0,0 +1,15 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; + +export default class BaseField extends Component { + get displayPlaceholders() { + return ( + this.args.placeholders?.length && this.args.field?.acceptsPlaceholders + ); + } + + @action + mutValue(newValue) { + this.args.field.metadata.value = newValue; + } +} diff --git a/plugins/automation/assets/javascripts/discourse/components/fields/da-boolean-field.gjs b/plugins/automation/assets/javascripts/discourse/components/fields/da-boolean-field.gjs new file mode 100644 index 00000000000..b6fee3315ae --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/fields/da-boolean-field.gjs @@ -0,0 +1,32 @@ +import { Input } from "@ember/component"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import BaseField from "./da-base-field"; +import DAFieldDescription from "./da-field-description"; +import DAFieldLabel from "./da-field-label"; + +export default class BooleanField extends BaseField { + + + @action + onInput(event) { + this.mutValue(event.target.checked); + } +} diff --git a/plugins/automation/assets/javascripts/discourse/components/fields/da-categories-field.gjs b/plugins/automation/assets/javascripts/discourse/components/fields/da-categories-field.gjs new file mode 100644 index 00000000000..396871054f1 --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/fields/da-categories-field.gjs @@ -0,0 +1,38 @@ +import { fn, hash } from "@ember/helper"; +import { action } from "@ember/object"; +import Category from "discourse/models/category"; +import CategorySelector from "select-kit/components/category-selector"; +import BaseField from "./da-base-field"; +import DAFieldDescription from "./da-field-description"; +import DAFieldLabel from "./da-field-label"; + +export default class CategoriesField extends BaseField { + + + get categories() { + const ids = this.args.field?.metadata?.value || []; + return ids.map((id) => Category.findById(id)).filter(Boolean); + } + + @action + onChangeCategories(categories) { + this.mutValue(categories.mapBy("id")); + } +} diff --git a/plugins/automation/assets/javascripts/discourse/components/fields/da-category-field.gjs b/plugins/automation/assets/javascripts/discourse/components/fields/da-category-field.gjs new file mode 100644 index 00000000000..fd933888f5b --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/fields/da-category-field.gjs @@ -0,0 +1,25 @@ +import { hash } from "@ember/helper"; +import CategoryChooser from "select-kit/components/category-chooser"; +import BaseField from "./da-base-field"; +import DAFieldDescription from "./da-field-description"; +import DAFieldLabel from "./da-field-label"; + +export default class CategoryField extends BaseField { + +} diff --git a/plugins/automation/assets/javascripts/discourse/components/fields/da-category-notification-level-field.gjs b/plugins/automation/assets/javascripts/discourse/components/fields/da-category-notification-level-field.gjs new file mode 100644 index 00000000000..b84e7d6e955 --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/fields/da-category-notification-level-field.gjs @@ -0,0 +1,25 @@ +import { hash } from "@ember/helper"; +import CategoryNotificationsButton from "select-kit/components/category-notifications-button"; +import BaseField from "./da-base-field"; +import DAFieldDescription from "./da-field-description"; +import DAFieldLabel from "./da-field-label"; + +export default class CategoryNotficationLevelField extends BaseField { + +} diff --git a/plugins/automation/assets/javascripts/discourse/components/fields/da-choices-field.gjs b/plugins/automation/assets/javascripts/discourse/components/fields/da-choices-field.gjs new file mode 100644 index 00000000000..78082136acc --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/fields/da-choices-field.gjs @@ -0,0 +1,38 @@ +import { hash } from "@ember/helper"; +import I18n from "I18n"; +import ComboBox from "select-kit/components/combo-box"; +import BaseField from "./da-base-field"; +import DAFieldDescription from "./da-field-description"; +import DAFieldLabel from "./da-field-label"; + +export default class ChoicesField extends BaseField { + + + get replacedContent() { + return (this.args.field.extra.content || []).map((r) => { + return { + id: r.id, + name: r.translated_name || I18n.t(r.name), + }; + }); + } +} diff --git a/plugins/automation/assets/javascripts/discourse/components/fields/da-custom-field.gjs b/plugins/automation/assets/javascripts/discourse/components/fields/da-custom-field.gjs new file mode 100644 index 00000000000..9f5f223e697 --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/fields/da-custom-field.gjs @@ -0,0 +1,37 @@ +import { tracked } from "@glimmer/tracking"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import { inject as service } from "@ember/service"; +import { bind } from "discourse-common/utils/decorators"; +import ComboBox from "select-kit/components/combo-box"; +import BaseField from "./da-base-field"; +import DAFieldDescription from "./da-field-description"; +import DAFieldLabel from "./da-field-label"; + +export default class GroupField extends BaseField { + @service store; + @tracked allCustomFields = []; + + + + @bind + loadUserFields() { + this.store.findAll("user-field").then((fields) => { + this.allCustomFields = fields.content; + }); + } +} diff --git a/plugins/automation/assets/javascripts/discourse/components/fields/da-custom-fields.gjs b/plugins/automation/assets/javascripts/discourse/components/fields/da-custom-fields.gjs new file mode 100644 index 00000000000..6f2866679a1 --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/fields/da-custom-fields.gjs @@ -0,0 +1,41 @@ +import { tracked } from "@glimmer/tracking"; +import { hash } from "@ember/helper"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import { inject as service } from "@ember/service"; +import { bind } from "discourse-common/utils/decorators"; +import MultiSelect from "select-kit/components/multi-select"; +import BaseField from "./da-base-field"; +import DAFieldDescription from "./da-field-description"; +import DAFieldLabel from "./da-field-label"; + +export default class GroupField extends BaseField { + @service store; + @tracked allCustomFields = []; + + + + @bind + loadUserFields() { + this.store.findAll("user-field").then((fields) => { + this.allCustomFields = fields.content.map((field) => field.name); + }); + } +} diff --git a/plugins/automation/assets/javascripts/discourse/components/fields/da-date-time-field.gjs b/plugins/automation/assets/javascripts/discourse/components/fields/da-date-time-field.gjs new file mode 100644 index 00000000000..2d68cb2d8af --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/fields/da-date-time-field.gjs @@ -0,0 +1,62 @@ +import { Input } from "@ember/component"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import DButton from "discourse/components/d-button"; +import BaseField from "./da-base-field"; +import DAFieldDescription from "./da-field-description"; +import DAFieldLabel from "./da-field-label"; + +export default class DateTimeField extends BaseField { + + + @action + convertToUniversalTime(event) { + const date = event.target.value; + if (!date) { + return; + } + + this.mutValue(moment(date).utc().format()); + } + + @action + reset() { + this.mutValue(null); + } + + get localTime() { + return ( + this.args.field.metadata.value && + moment(this.args.field.metadata.value) + .local() + .format(moment.HTML5_FMT.DATETIME_LOCAL) + ); + } +} diff --git a/plugins/automation/assets/javascripts/discourse/components/fields/da-email-group-user-field.gjs b/plugins/automation/assets/javascripts/discourse/components/fields/da-email-group-user-field.gjs new file mode 100644 index 00000000000..d9b912d8bc3 --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/fields/da-email-group-user-field.gjs @@ -0,0 +1,61 @@ +import { tracked } from "@glimmer/tracking"; +import { hash } from "@ember/helper"; +import { action } from "@ember/object"; +import EmailGroupUserChooser from "select-kit/components/email-group-user-chooser"; +import BaseField from "./da-base-field"; +import DAFieldDescription from "./da-field-description"; +import DAFieldLabel from "./da-field-label"; + +export default class EmailGroupUserField extends BaseField { + @tracked recipients; + @tracked groups = []; + + + + @action + updateRecipients(selected, content) { + const newGroups = content.filterBy("isGroup").mapBy("id"); + this._updateGroups(selected, newGroups); + this.recipients = selected.join(","); + } + + _updateGroups(selected, newGroups) { + const groups = []; + + this.groups.forEach((existing) => { + if (selected.includes(existing)) { + groups.addObject(existing); + } + }); + + newGroups.forEach((newGroup) => { + if (!groups.includes(newGroup)) { + groups.addObject(newGroup); + } + }); + + this.groups = groups; + } +} diff --git a/plugins/automation/assets/javascripts/discourse/components/fields/da-field-description.gjs b/plugins/automation/assets/javascripts/discourse/components/fields/da-field-description.gjs new file mode 100644 index 00000000000..fbba9ab9d7e --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/fields/da-field-description.gjs @@ -0,0 +1,9 @@ +const FieldDescription = ; + +export default FieldDescription; diff --git a/plugins/automation/assets/javascripts/discourse/components/fields/da-field-label.gjs b/plugins/automation/assets/javascripts/discourse/components/fields/da-field-label.gjs new file mode 100644 index 00000000000..1b35f80b46f --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/fields/da-field-label.gjs @@ -0,0 +1,14 @@ +const FieldLabel = ; + +export default FieldLabel; diff --git a/plugins/automation/assets/javascripts/discourse/components/fields/da-group-field.gjs b/plugins/automation/assets/javascripts/discourse/components/fields/da-group-field.gjs new file mode 100644 index 00000000000..00c131773e7 --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/fields/da-group-field.gjs @@ -0,0 +1,51 @@ +import { tracked } from "@glimmer/tracking"; +import { hash } from "@ember/helper"; +import { action } from "@ember/object"; +import Group from "discourse/models/group"; +import GroupChooser from "select-kit/components/group-chooser"; +import BaseField from "./da-base-field"; +import DAFieldDescription from "./da-field-description"; +import DAFieldLabel from "./da-field-label"; + +export default class GroupField extends BaseField { + @tracked allGroups = []; + + + + constructor() { + super(...arguments); + + Group.findAll({ + ignore_automatic: this.args.field.extra.ignore_automatic ?? false, + }).then((groups) => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.allGroups = groups; + }); + } + + @action + setGroupField(groupIds) { + this.mutValue(groupIds?.firstObject); + } +} diff --git a/plugins/automation/assets/javascripts/discourse/components/fields/da-key-value-field.gjs b/plugins/automation/assets/javascripts/discourse/components/fields/da-key-value-field.gjs new file mode 100644 index 00000000000..669b6887df9 --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/fields/da-key-value-field.gjs @@ -0,0 +1,104 @@ +import { tracked } from "@glimmer/tracking"; +import { hash } from "@ember/helper"; +import { action } from "@ember/object"; +import DButton from "discourse/components/d-button"; +import ModalJsonSchemaEditor from "discourse/components/modal/json-schema-editor"; +import I18n from "I18n"; +import BaseField from "./da-base-field"; +import DAFieldDescription from "./da-field-description"; +import DAFieldLabel from "./da-field-label"; + +export default class KeyValueField extends BaseField { + @tracked showJsonEditorModal = false; + + jsonSchema = { + type: "array", + uniqueItems: true, + items: { + type: "object", + title: "group", + properties: { + key: { + type: "string", + }, + value: { + type: "string", + format: "textarea", + }, + }, + }, + }; + + + + get value() { + return ( + this.args.field.metadata.value || + '[{"key":"example","value":"You posted {{key}}"}]' + ); + } + + get keyCount() { + if (this.args.field.metadata.value) { + return JSON.parse(this.value).length; + } + + return 0; + } + + get showJsonModalLabel() { + if (this.keyCount === 0) { + return I18n.t( + "discourse_automation.fields.key_value.label_without_count" + ); + } else { + return I18n.t("discourse_automation.fields.key_value.label_with_count", { + count: this.keyCount, + }); + } + } + + @action + handleValueChange(value) { + if (value !== this.args.field.metadata.value) { + this.mutValue(value); + this.args.saveAutomation(); + } + } + + @action + openModal() { + this.showJsonEditorModal = true; + } + + @action + closeModal() { + this.showJsonEditorModal = false; + } +} diff --git a/plugins/automation/assets/javascripts/discourse/components/fields/da-message-field.gjs b/plugins/automation/assets/javascripts/discourse/components/fields/da-message-field.gjs new file mode 100644 index 00000000000..e9c1ea2fea1 --- /dev/null +++ b/plugins/automation/assets/javascripts/discourse/components/fields/da-message-field.gjs @@ -0,0 +1,41 @@ +import { TextArea } from "@ember/legacy-built-in-components"; +import { action } from "@ember/object"; +import PlaceholdersList from "../placeholders-list"; +import BaseField from "./da-base-field"; +import DAFieldDescription from "./da-field-description"; +import DAFieldLabel from "./da-field-label"; + +export default class MessageField extends BaseField { +