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}}
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