From 9abeff460ca18d22ec9c5124a4c995af56a1de14 Mon Sep 17 00:00:00 2001 From: Gary Pendergast Date: Tue, 4 Mar 2025 17:22:12 +1100 Subject: [PATCH] FEATURE: Display the Watched Words that caused a post to be flagged. (#31435) When a post is flagged due to matching watched words, it can be difficult to know what you're looking for, particularly if you have a lot of watched words built up over a long period of time. This change stores the list of matched words, and later displays them in the review queue, listing which Watched Words were responsible for the flag. Because watched words can change, this is recorded at the time the post is flagged. For posts that were flagged prior to this feature landing, it tries to guess the relevant words based on the current Watched Words set. --- .../app/components/reviewable-scores.hbs | 3 ++ app/jobs/regular/process_post.rb | 5 ++- app/models/reviewable.rb | 4 +- app/models/reviewable_score.rb | 1 + .../reviewable_score_serializer.rb | 36 ++++++++++++++++- config/locales/server.en.yml | 8 +++- ...045740_add_context_to_reviewable_scores.rb | 6 +++ lib/post_action_creator.rb | 10 ++++- spec/integration/watched_words_spec.rb | 39 ++++++++++++++++++- .../reviewable_score_serializer_spec.rb | 33 ++++++++++++++++ 10 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 db/migrate/20250220045740_add_context_to_reviewable_scores.rb diff --git a/app/assets/javascripts/discourse/app/components/reviewable-scores.hbs b/app/assets/javascripts/discourse/app/components/reviewable-scores.hbs index 73d268380da..462acc3d6ce 100644 --- a/app/assets/javascripts/discourse/app/components/reviewable-scores.hbs +++ b/app/assets/javascripts/discourse/app/components/reviewable-scores.hbs @@ -23,6 +23,9 @@ {{#if rs.reason}}
{{html-safe rs.reason}}
{{/if}} + {{#if rs.context}} +
{{html-safe rs.context}}
+ {{/if}} {{#if rs.reviewable_conversation}}
diff --git a/app/jobs/regular/process_post.rb b/app/jobs/regular/process_post.rb index f51acb11ea0..9fba5282252 100644 --- a/app/jobs/regular/process_post.rb +++ b/app/jobs/regular/process_post.rb @@ -49,12 +49,15 @@ module Jobs if !post.user&.staff? && !post.user&.staged? s = post.raw s << " #{post.topic.title}" if post.post_number == 1 - if !args[:bypass_bump] && WordWatcher.new(s).should_flag? + word_watcher = WordWatcher.new(s) + if !args[:bypass_bump] && word_watcher.should_flag? + words = word_watcher.word_matches_for_action?(:flag, all_matches: true) PostActionCreator.create( Discourse.system_user, post, :inappropriate, reason: :watched_word, + context: words.join(","), ) end end diff --git a/app/models/reviewable.rb b/app/models/reviewable.rb index 054c12cdeb9..87385d548f5 100644 --- a/app/models/reviewable.rb +++ b/app/models/reviewable.rb @@ -199,7 +199,8 @@ class Reviewable < ActiveRecord::Base created_at: nil, take_action: false, meta_topic_id: nil, - force_review: false + force_review: false, + context: nil ) type_bonus = PostActionType.where(id: reviewable_score_type).pluck(:score_bonus)[0] || 0 take_action_bonus = take_action ? 5.0 : 0.0 @@ -216,6 +217,7 @@ class Reviewable < ActiveRecord::Base meta_topic_id: meta_topic_id, take_action_bonus: take_action_bonus, created_at: created_at || Time.zone.now, + context: context, ) rs.reason = reason.to_s if reason rs.save! diff --git a/app/models/reviewable_score.rb b/app/models/reviewable_score.rb index 17300a52e8d..38584c0d4fd 100644 --- a/app/models/reviewable_score.rb +++ b/app/models/reviewable_score.rb @@ -117,6 +117,7 @@ end # updated_at :datetime not null # reason :string # user_accuracy_bonus :float default(0.0), not null +# context :string # # Indexes # diff --git a/app/serializers/reviewable_score_serializer.rb b/app/serializers/reviewable_score_serializer.rb index 724ec93efda..f7802e9719c 100644 --- a/app/serializers/reviewable_score_serializer.rb +++ b/app/serializers/reviewable_score_serializer.rb @@ -42,7 +42,12 @@ class ReviewableScoreSerializer < ApplicationSerializer if link_text link = build_link_for(object.reason, link_text) - text = I18n.t("reviewables.reasons.#{object.reason}", link: link, default: object.reason) + + if object.reason == "watched_word" + text = watched_word_reason(link) + else + text = I18n.t("reviewables.reasons.#{object.reason}", link: link, default: object.reason) + end else text = I18n.t("reviewables.reasons.#{object.reason}", default: object.reason) end @@ -69,6 +74,35 @@ class ReviewableScoreSerializer < ApplicationSerializer private + def watched_word_reason(link) + if object.context.nil? + # If the words weren't recorded, try to guess them based on current settings. + words = + WordWatcher.new(object.reviewable.post.raw).word_matches_for_action?( + :flag, + all_matches: true, + ) + else + words = object.context.split(",") + end + + if words.nil? + text = + I18n.t("reviewables.reasons.no_context.watched_word", link: link, default: "watched_word") + else + text = + I18n.t( + "reviewables.reasons.watched_word", + link: link, + words: words.join(", "), + count: words.length, + default: "watched_word", + ) + end + + text + end + def url_for(reason, text) case reason when "watched_word" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 9868dff8f54..e7e6d2e209b 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -5584,7 +5584,9 @@ en: new_topics_unless_trust_level: "Users at low trust levels must have topics approved by staff. See %{link}." fast_typer: "New user typed their first post suspiciously fast, suspected bot or spammer behavior. See %{link}." auto_silence_regex: "New user whose first post matches the %{link} setting." - watched_word: "This post included a Watched Word. See your %{link}." + watched_word: + one: "This post included a Watched Word, the flagged word is: %{words}. Check the %{link} for more." + other: "This post included Watched Words, the flagged words are: %{words}. Check the %{link} for more." staged: "New topics and posts for staged users must be approved by staff. See %{link}." category: "Posts in this category require manual approval by staff. See the %{link}." must_approve_users: "All new users must be approved by staff. See %{link}." @@ -5595,8 +5597,10 @@ en: contains_media: "This post includes embedded media. See %{link}." queued_by_staff: "A staff member thinks this post needs review. It'll remain hidden until then." links: - watched_word: list of watched words + watched_word: watched word settings category: category settings + no_context: + watched_word: "This post included a Watched Word. Check the %{link} for more." actions: agree: diff --git a/db/migrate/20250220045740_add_context_to_reviewable_scores.rb b/db/migrate/20250220045740_add_context_to_reviewable_scores.rb new file mode 100644 index 00000000000..3ffa1938cde --- /dev/null +++ b/db/migrate/20250220045740_add_context_to_reviewable_scores.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class AddContextToReviewableScores < ActiveRecord::Migration[7.2] + def change + add_column :reviewable_scores, :context, :string + end +end diff --git a/lib/post_action_creator.rb b/lib/post_action_creator.rb index d0de531ae45..d971569fe2b 100644 --- a/lib/post_action_creator.rb +++ b/lib/post_action_creator.rb @@ -14,7 +14,8 @@ class PostActionCreator message: nil, created_at: nil, reason: nil, - silent: false + silent: false, + context: nil ) new( created_by, @@ -24,6 +25,7 @@ class PostActionCreator created_at: created_at, reason: reason, silent: silent, + context: context, ).perform end @@ -50,7 +52,8 @@ class PostActionCreator created_at: nil, queue_for_review: false, reason: nil, - silent: false + silent: false, + context: nil ) @created_by = created_by @created_at = created_at || Time.zone.now @@ -73,6 +76,8 @@ class PostActionCreator @reason = "queued_by_staff" if reason.nil? && @queue_for_review @silent = silent + + @context = context end def post_can_act? @@ -403,6 +408,7 @@ class PostActionCreator meta_topic_id: @meta_post&.topic_id, reason: @reason, force_review: trusted_spam_flagger?, + context: @context, ) end diff --git a/spec/integration/watched_words_spec.rb b/spec/integration/watched_words_spec.rb index 491919f92d8..4e7c322a4a2 100644 --- a/spec/integration/watched_words_spec.rb +++ b/spec/integration/watched_words_spec.rb @@ -12,6 +12,7 @@ RSpec.describe WatchedWord do Fabricate(:watched_word, action: WatchedWord.actions[:require_approval]) end let(:flag_word) { Fabricate(:watched_word, action: WatchedWord.actions[:flag]) } + let(:another_flag_word) { Fabricate(:watched_word, action: WatchedWord.actions[:flag]) } let(:block_word) { Fabricate(:watched_word, action: WatchedWord.actions[:block]) } let(:another_block_word) { Fabricate(:watched_word, action: WatchedWord.actions[:block]) } @@ -232,7 +233,43 @@ RSpec.describe WatchedWord do ).to eq(true) reviewable = ReviewableFlaggedPost.where(target: post) expect(reviewable).to be_present - expect(ReviewableScore.where(reviewable: reviewable, reason: "watched_word")).to be_present + expect( + ReviewableScore.where( + reviewable: reviewable, + reason: "watched_word", + context: flag_word.word, + ), + ).to be_present + end + + it "should flag the post if it contains multiple flagged words" do + topic = Fabricate(:topic, user: tl2_user) + post = + Fabricate( + :post, + raw: + "I said.... #{flag_word.word} and #{another_flag_word.word} and #{flag_word.word} again", + topic: topic, + user: tl2_user, + ) + expect { Jobs::ProcessPost.new.execute(post_id: post.id) }.to change { PostAction.count }.by( + 1, + ) + expect( + PostAction.where( + post_id: post.id, + post_action_type_id: PostActionType.types[:inappropriate], + ).exists?, + ).to eq(true) + reviewable = ReviewableFlaggedPost.where(target: post) + expect(reviewable).to be_present + expect( + ReviewableScore.where( + reviewable: reviewable, + reason: "watched_word", + context: "#{flag_word.word},#{another_flag_word.word}", + ), + ).to be_present end it "should look at the title too" do diff --git a/spec/serializers/reviewable_score_serializer_spec.rb b/spec/serializers/reviewable_score_serializer_spec.rb index cd603e7de02..7e2aa616f62 100644 --- a/spec/serializers/reviewable_score_serializer_spec.rb +++ b/spec/serializers/reviewable_score_serializer_spec.rb @@ -63,6 +63,39 @@ RSpec.describe ReviewableScoreSerializer do expect(serialized.reason).to eq(custom) end end + + context "with watched words" do + let(:link) do + "#{I18n.t("reviewables.reasons.links.watched_word")}" + end + it "tries to guess the watched words if they weren't recorded at the time of flagging" do + reviewable.target = + Fabricate(:post, raw: "I'm a post with some bad words like 'bad' and 'words'.") + + score = serialized_score("watched_word") + + Fabricate(:watched_word, action: WatchedWord.actions[:flag], word: "bad") + Fabricate(:watched_word, action: WatchedWord.actions[:flag], word: "words") + + expect(score.reason).to include("bad, words") + end + + it "uses the no-context message if the post has no watched words" do + reviewable.target = Fabricate(:post, raw: "This post contains no bad words.") + + score = serialized_score("watched_word") + + Fabricate(:watched_word, action: WatchedWord.actions[:flag], word: "superbad") + + expect(score.reason).to eq( + I18n.t( + "reviewables.reasons.no_context.watched_word", + link: link, + default: "watched_word", + ), + ) + end + end end describe "#setting_name_for_reason" do