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