discourse/plugins/automation/lib/discourse_automation/event_handlers.rb
Kris 20552f0fa7
DEV: Allow AI tagging automation to tag system-created topics (#35245)
I want to use the AI llm_tagger script on
https://discover.discourse.com, but in order to do this the automation
needs to be allowed to run on posts created by the system user.

To accomplish this, I've added the ability for scripts to add a
`allow_system_posts` setting. Existing scripts will still ignore system
posts, but this allows script authors to add a setting to opt-in to
processing them.
2025-10-08 11:47:16 -04:00

434 lines
16 KiB
Ruby

# frozen_string_literal: true
module DiscourseAutomation
module EventHandlers
def self.handle_post_created_edited(post, action)
return if post.post_type != Post.types[:regular]
topic = post.topic
return if topic.blank?
name = DiscourseAutomation::Triggers::POST_CREATED_EDITED
DiscourseAutomation::Automation
.where(trigger: name, enabled: true)
.find_each do |automation|
# allow scripts to opt-in to system posts via allow_system_posts setting
next if post.user_id < 0 && !automation.script_field("allow_system_posts")&.dig("value")
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
restricted_archetype = automation.trigger_field("restricted_archetype")["value"]
if restricted_archetype.present?
if restricted_archetype == "public"
next if topic.archetype != Archetype.default
next if !topic.category
next if topic.category.read_restricted?
else
topic_archetype = topic.archetype
next if restricted_archetype != topic_archetype
end
end
original_post_only = automation.trigger_field("original_post_only")
if original_post_only["value"]
next if post.post_number != 1
end
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
skip_via_email = automation.trigger_field("skip_via_email")
if skip_via_email["value"]
next if post.via_email?
end
valid_trust_levels = automation.trigger_field("valid_trust_levels")
if valid_trust_levels["value"]
next if valid_trust_levels["value"].exclude?(post.user.trust_level)
end
restricted_categories = automation.trigger_field("restricted_categories").dup
if restricted_category_ids = restricted_categories["value"]
exclude_subcategories = automation.trigger_field("exclude_subcategories")["value"]
if !exclude_subcategories
# core api is odd, we only support nesting of 3 anyway, this is efficient
restricted_category_ids = restricted_category_ids.to_set
# for nesting of 3
restricted_category_ids +=
Category.where(parent_category_id: restricted_category_ids).pluck(:id)
restricted_category_ids +=
Category.where(parent_category_id: restricted_category_ids).pluck(:id)
end
next if !restricted_category_ids.include?(topic.category_id)
end
restricted_tags = automation.trigger_field("restricted_tags")
if restricted_tags["value"]
next if (restricted_tags["value"] & topic.tags.map(&:name)).empty?
end
restricted_inbox_group_ids = automation.trigger_field("restricted_inbox_groups")["value"]
if restricted_inbox_group_ids.present?
next if !topic.private_message?
target_group_ids = topic.allowed_groups.pluck(:id)
next if (restricted_inbox_group_ids & target_group_ids).empty?
end
user_group_ids = automation.trigger_field("restricted_groups")["value"]
next if user_group_ids.present? && !post.user.in_any_groups?(user_group_ids)
excluded_group_ids = automation.trigger_field("excluded_groups")["value"]
next if excluded_group_ids.present? && post.user.in_any_groups?(excluded_group_ids)
ignore_automated = automation.trigger_field("ignore_automated")
next if ignore_automated["value"] && post.incoming_email&.is_auto_generated?
post_features = automation.trigger_field("post_features")["value"]
if post_features.present?
cooked = post.cooked
# note the only 100% correct way is to lean on an actual HTML parser
# however triggers may pop up during the post creation process, we can not afford a full parse
if post_features.include?("with_images") &&
!cooked.match?(/<img(?![^>]*class=["'](emoji|avatar))[^>]*>/i)
next
end
next if post_features.include?("with_links") && !cooked.match?(/<a\s+[^>]*>/i)
next if post_features.include?("with_code") && !cooked.match?(/<pre[^>]*>/i)
if post_features.include?("with_uploads") &&
!cooked.match?(/<a\s+[^>]*class=["']attachment[^>]*>/i)
next
end
end
automation.trigger!(
"kind" => name,
"action" => action,
"post" => post,
"placeholders" => {
"topic_url" => topic.relative_url,
"topic_title" => topic.title,
},
)
end
end
def self.handle_user_updated(user, new_user: false)
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 &&
user.custom_fields[
DiscourseAutomation::AUTOMATION_IDS_CUSTOM_FIELD
].presence&.include?(automation.id)
next
end
new_users_only = automation.trigger_field("new_users_only")["value"]
new_user_custom_field = automation.new_user_custom_field_name
new_user ||= user.custom_fields[new_user_custom_field].present?
next if new_users_only && !new_user
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?
}
if new_users_only
user.custom_fields[new_user_custom_field] = "1"
user.save_custom_fields
end
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?
}
if new_users_only
user.custom_fields[new_user_custom_field] = "1"
user.save_custom_fields
end
next
end
user_data[:profile_data] = user_profile_data
end
if new_users_only && once_per_user
user.custom_fields.delete(new_user_custom_field)
user.save_custom_fields
end
automation.add_id_to_custom_field(user, DiscourseAutomation::AUTOMATION_IDS_CUSTOM_FIELD)
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_topic_tags_changed(topic, old_tag_names, new_tag_names, user)
name = DiscourseAutomation::Triggers::TOPIC_TAGS_CHANGED
DiscourseAutomation::Automation
.where(trigger: name, enabled: true)
.find_each do |automation|
if topic.private_message?
next if !automation.trigger_field("trigger_with_pms")["value"]
end
watching_categories = automation.trigger_field("watching_categories")
if watching_categories["value"]
next if !watching_categories["value"].include?(topic.category_id)
end
removed_tags = old_tag_names - new_tag_names
added_tags = new_tag_names - old_tag_names
watching_tags = automation.trigger_field("watching_tags")
if watching_tags["value"]
changed_tags = (removed_tags | added_tags)
next if (changed_tags & watching_tags["value"]).empty?
end
trigger_on = automation.trigger_field("trigger_on")["value"]
if trigger_on == Triggers::TopicTagsChanged::TriggerOn::TAGS_ADDED && added_tags.empty?
next
end
if trigger_on == Triggers::TopicTagsChanged::TriggerOn::TAGS_REMOVED &&
removed_tags.empty?
next
end
automation.trigger!(
"kind" => name,
"topic" => topic,
"removed_tags" => removed_tags,
"added_tags" => added_tags,
"user" => user,
"placeholders" => {
"topic_url" => topic.relative_url,
"topic_title" => topic.title,
},
)
end
end
def self.handle_topic_closed(topic)
name = DiscourseAutomation::Triggers::TOPIC_CLOSED
DiscourseAutomation::Automation
.where(trigger: name, enabled: true)
.find_each do |automation|
automation.trigger!(
"kind" => name,
"topic" => topic,
"placeholders" => {
"topic_url" => topic.relative_url,
"topic_title" => topic.title,
},
)
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 if valid_trust_levels["value"].exclude?(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&.any? && (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