From b49b455e4705712af71650229104c984a03e3297 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Wed, 3 Mar 2021 10:53:38 +0200 Subject: [PATCH] FEATURE: Autotag watched words (#12244) New topics with be matched against a set of watched words and be tagged accordingly. --- .../addon/components/watched-word-form.js | 9 ++--- .../components/watched-word-form.hbs | 7 ++++ app/jobs/regular/process_post.rb | 21 ++++++++++- app/models/watched_word.rb | 3 +- app/serializers/watched_word_serializer.rb | 2 +- app/services/word_watcher.rb | 12 +++++- config/locales/client.en.yml | 4 ++ config/locales/server.en.yml | 1 + spec/jobs/process_post_spec.rb | 37 +++++++++++++++++++ 9 files changed, 86 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/admin/addon/components/watched-word-form.js b/app/assets/javascripts/admin/addon/components/watched-word-form.js index cf0ceb3f455..cf957148e2b 100644 --- a/app/assets/javascripts/admin/addon/components/watched-word-form.js +++ b/app/assets/javascripts/admin/addon/components/watched-word-form.js @@ -6,6 +6,7 @@ import Component from "@ember/component"; import I18n from "I18n"; import WatchedWord from "admin/models/watched-word"; import bootbox from "bootbox"; +import { equal } from "@ember/object/computed"; import { isEmpty } from "@ember/utils"; import { schedule } from "@ember/runloop"; @@ -15,10 +16,8 @@ export default Component.extend({ actionKey: null, showMessage: false, - @discourseComputed("actionKey") - canReplace(actionKey) { - return actionKey === "replace"; - }, + canReplace: equal("actionKey", "replace"), + canTag: equal("actionKey", "tag"), @discourseComputed("regularExpressions") placeholderKey(regularExpressions) { @@ -61,7 +60,7 @@ export default Component.extend({ const watchedWord = WatchedWord.create({ word: this.word, - replacement: this.canReplace ? this.replacement : null, + replacement: this.canReplace || this.canTag ? this.replacement : null, action: this.actionKey, }); diff --git a/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs b/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs index 79231fa8044..66b148c303f 100644 --- a/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs @@ -10,6 +10,13 @@ {{/if}} +{{#if canTag}} +
+ + {{text-field id="watched-tag" value=replacement disabled=formSubmitted class="watched-word-input" autocorrect="off" autocapitalize="off" placeholderKey="admin.watched_words.form.tag_placeholder"}} +
+{{/if}} + {{d-button class="btn-default" action=(action "submit") disabled=formSubmitted label="admin.watched_words.form.add"}} {{#if showMessage}} diff --git a/app/jobs/regular/process_post.rb b/app/jobs/regular/process_post.rb index 6ba04d4f5b2..9ff98cae223 100644 --- a/app/jobs/regular/process_post.rb +++ b/app/jobs/regular/process_post.rb @@ -29,13 +29,13 @@ module Jobs cooked = cp.html if cooked != (recooked || orig_cooked) - if orig_cooked.present? && cooked.blank? # TODO stop/restart the worker if needed, let's gather a few here first Rails.logger.warn("Cooked post processor in FATAL state, bypassing. You need to urgently restart sidekiq\norig: #{orig_cooked}\nrecooked: #{recooked}\ncooked: #{cooked}\npost id: #{post.id}") else post.update_column(:cooked, cp.html) extract_links(post) + auto_tag(post) if SiteSetting.tagging_enabled? && post.post_number == 1 post.publish_change_to_clients! :revised end end @@ -60,6 +60,25 @@ module Jobs TopicLink.extract_from(post) QuotedPost.extract_from(post) end + + def auto_tag(post) + word_watcher = WordWatcher.new(post.raw) + + old_tags = post.topic.tags.pluck(:name).to_set + new_tags = old_tags.dup + + WordWatcher.words_for_action(:tag).each do |word, tags| + new_tags += tags.split(",") if word_watcher.matches?(word) + end + + if old_tags != new_tags + post.revise( + Discourse.system_user, + tags: new_tags.to_a, + edit_reason: I18n.t(:watched_words_auto_tag) + ) + end + end end end diff --git a/app/models/watched_word.rb b/app/models/watched_word.rb index 200059978e3..2cbe61dcc12 100644 --- a/app/models/watched_word.rb +++ b/app/models/watched_word.rb @@ -8,7 +8,8 @@ class WatchedWord < ActiveRecord::Base censor: 2, require_approval: 3, flag: 4, - replace: 5 + replace: 5, + tag: 6, ) end diff --git a/app/serializers/watched_word_serializer.rb b/app/serializers/watched_word_serializer.rb index 65423716886..f96fbde53fd 100644 --- a/app/serializers/watched_word_serializer.rb +++ b/app/serializers/watched_word_serializer.rb @@ -8,6 +8,6 @@ class WatchedWordSerializer < ApplicationSerializer end def include_replacement? - action == :replace + action == :replace || action == :tag end end diff --git a/app/services/word_watcher.rb b/app/services/word_watcher.rb index 92d278b6b37..3665c72102e 100644 --- a/app/services/word_watcher.rb +++ b/app/services/word_watcher.rb @@ -8,7 +8,7 @@ class WordWatcher def self.words_for_action(action) words = WatchedWord.where(action: WatchedWord.actions[action.to_sym]).limit(1000) - if action.to_sym == :replace + if action.to_sym == :replace || action.to_sym == :tag words.pluck(:word, :replacement).to_h else words.pluck(:word) @@ -31,7 +31,7 @@ class WordWatcher def self.word_matcher_regexp(action, raise_errors: false) words = get_cached_words(action) if words - if action.to_sym == :replace + if action.to_sym == :replace || action.to_sym == :tag words = words.keys end words = words.map do |w| @@ -110,4 +110,12 @@ class WordWatcher false end end + + def matches?(word) + if SiteSetting.watched_words_regular_expressions? + Regexp.new(word).match?(@raw) + else + @raw.include?(word) + end + end end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 5410a36cc6b..de65de44aa0 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -4621,18 +4621,22 @@ en: require_approval: "Require Approval" flag: "Flag" replace: "Replace" + tag: "Auto-tag" action_descriptions: block: "Prevent posts containing these words from being posted. The user will see an error message when they try to submit their post." censor: "Allow posts containing these words, but replace them with characters that hide the censored words." require_approval: "Posts containing these words will require approval by staff before they can be seen." flag: "Allow posts containing these words, but flag them as inappropriate so moderators can review them." replace: "Replace words in posts with other words or links" + tag: "Automatically tag posts with these words" form: label: "New Word" placeholder: "full word or * as wildcard" placeholder_regexp: "regular expression" replacement_label: "Replacement" replacement_placeholder: "example or https://example.com" + tag_label: "Tag" + tag_placeholder: "tag1,tag2,tag3" add: "Add" success: "Success" exists: "Already exists" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 562928b8b0a..43a183883d1 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -361,6 +361,7 @@ en: max_pm_recipients: "Sorry, you can send a message to maximum %{recipients_limit} recipients." pm_reached_recipients_limit: "Sorry, you can't have more than %{recipients_limit} recipients in a message." removed_direct_reply_full_quotes: "Automatically removed quote of whole previous post." + watched_words_auto_tag: "Automatically tagged topic" secure_upload_not_allowed_in_public_topic: "Sorry, the following secure upload(s) cannot be used in a public topic: %{upload_filenames}." create_pm_on_existing_topic: "Sorry, you can't create a PM on an existing topic." slow_mode_enabled: "This topic is in slow mode." diff --git a/spec/jobs/process_post_spec.rb b/spec/jobs/process_post_spec.rb index 35794be2959..c6f8ecb395d 100644 --- a/spec/jobs/process_post_spec.rb +++ b/spec/jobs/process_post_spec.rb @@ -77,6 +77,43 @@ describe Jobs::ProcessPost do post.reload expect(post.cooked).to eq(cooked) end + + it "automatically tags first posts" do + SiteSetting.tagging_enabled = true + + Fabricate(:watched_word, action: WatchedWord.actions[:tag], word: "Greetings?", replacement: "hello , world") + + post = Fabricate(:post, raw: "Greeting", cooked: "") + Jobs::ProcessPost.new.execute(post_id: post.id) + expect(post.topic.reload.tags.pluck(:name)).to contain_exactly() + + post = Fabricate(:post, raw: "Greetings", cooked: "") + Jobs::ProcessPost.new.execute(post_id: post.id) + expect(post.topic.reload.tags.pluck(:name)).to contain_exactly() + + post = Fabricate(:post, raw: "Greetings?", cooked: "") + Jobs::ProcessPost.new.execute(post_id: post.id) + expect(post.topic.reload.tags.pluck(:name)).to contain_exactly("hello", "world") + end + + it "automatically tags first posts (regex)" do + SiteSetting.tagging_enabled = true + SiteSetting.watched_words_regular_expressions = true + + Fabricate(:watched_word, action: WatchedWord.actions[:tag], word: "Greetings?", replacement: "hello , world") + + post = Fabricate(:post, raw: "Greeting", cooked: "") + Jobs::ProcessPost.new.execute(post_id: post.id) + expect(post.topic.reload.tags.pluck(:name)).to contain_exactly("hello", "world") + + post = Fabricate(:post, raw: "Greetings", cooked: "") + Jobs::ProcessPost.new.execute(post_id: post.id) + expect(post.topic.reload.tags.pluck(:name)).to contain_exactly("hello", "world") + + post = Fabricate(:post, raw: "Greetings?", cooked: "") + Jobs::ProcessPost.new.execute(post_id: post.id) + expect(post.topic.reload.tags.pluck(:name)).to contain_exactly("hello", "world") + end end end