mirror of
https://github.com/codeninjasllc/discourse.git
synced 2024-11-27 17:46:05 -05:00
FEATURE: auto-close topics based on community flags
This commit is contained in:
parent
968064c791
commit
cd170ca548
7 changed files with 208 additions and 8 deletions
|
@ -1,5 +1,6 @@
|
||||||
require_dependency 'rate_limiter'
|
require_dependency 'rate_limiter'
|
||||||
require_dependency 'system_message'
|
require_dependency 'system_message'
|
||||||
|
require_dependency 'maximum_flow'
|
||||||
|
|
||||||
class PostAction < ActiveRecord::Base
|
class PostAction < ActiveRecord::Base
|
||||||
class AlreadyActed < StandardError; end
|
class AlreadyActed < StandardError; end
|
||||||
|
@ -232,7 +233,7 @@ class PostAction < ActiveRecord::Base
|
||||||
else
|
else
|
||||||
post_action = PostAction.where(where_attrs).first
|
post_action = PostAction.where(where_attrs).first
|
||||||
|
|
||||||
# after_commit is not called on an `update_all` so do the notify ourselves
|
# after_commit is not called on an 'update_all' so do the notify ourselves
|
||||||
post_action.notify_subscribers
|
post_action.notify_subscribers
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -349,7 +350,7 @@ class PostAction < ActiveRecord::Base
|
||||||
# Voting also changes the sort_order
|
# Voting also changes the sort_order
|
||||||
Post.where(id: post_id).update_all ["vote_count = :count, sort_order = :max - :count", count: count, max: Topic.max_sort_order]
|
Post.where(id: post_id).update_all ["vote_count = :count, sort_order = :max - :count", count: count, max: Topic.max_sort_order]
|
||||||
when :like
|
when :like
|
||||||
# `like_score` is weighted higher for staff accounts
|
# 'like_score' is weighted higher for staff accounts
|
||||||
score = PostAction.joins(:user)
|
score = PostAction.joins(:user)
|
||||||
.where(post_id: post_id)
|
.where(post_id: post_id)
|
||||||
.sum("CASE WHEN users.moderator OR users.admin THEN #{SiteSetting.staff_like_weight} ELSE 1 END")
|
.sum("CASE WHEN users.moderator OR users.admin THEN #{SiteSetting.staff_like_weight} ELSE 1 END")
|
||||||
|
@ -370,6 +371,7 @@ class PostAction < ActiveRecord::Base
|
||||||
|
|
||||||
def enforce_rules
|
def enforce_rules
|
||||||
post = Post.with_deleted.where(id: post_id).first
|
post = Post.with_deleted.where(id: post_id).first
|
||||||
|
PostAction.auto_close_if_treshold_reached(post.topic)
|
||||||
PostAction.auto_hide_if_needed(user, post, post_action_type_key)
|
PostAction.auto_hide_if_needed(user, post, post_action_type_key)
|
||||||
SpamRulesEnforcer.enforce!(post.user) if post_action_type_key == :spam
|
SpamRulesEnforcer.enforce!(post.user) if post_action_type_key == :spam
|
||||||
end
|
end
|
||||||
|
@ -380,6 +382,79 @@ class PostAction < ActiveRecord::Base
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
MAXIMUM_FLAGS_PER_POST = 3
|
||||||
|
|
||||||
|
def self.auto_close_if_treshold_reached(topic)
|
||||||
|
return if topic.closed?
|
||||||
|
|
||||||
|
# 1) retrieve a list of pairs (user_id, post_id) representing active flags
|
||||||
|
flags = PostAction.active
|
||||||
|
.flags
|
||||||
|
.joins(:post)
|
||||||
|
.where("posts.topic_id = ?", topic.id)
|
||||||
|
.where.not(user_id: Discourse::SYSTEM_USER_ID)
|
||||||
|
.pluck(:user_id, :post_id)
|
||||||
|
|
||||||
|
# check we have enough flags
|
||||||
|
return if flags.count < SiteSetting.num_flags_to_close_topic
|
||||||
|
|
||||||
|
# 2) build sets of unique user_ids and post_ids
|
||||||
|
user_ids = Set.new
|
||||||
|
post_ids = Set.new
|
||||||
|
|
||||||
|
flags.each do |f|
|
||||||
|
user_ids << f[0]
|
||||||
|
post_ids << f[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
# check we have enough flaggers
|
||||||
|
return if user_ids.count < SiteSetting.num_flaggers_to_close_topic
|
||||||
|
# check we have enough posts flagged
|
||||||
|
min_post_required = SiteSetting.num_flags_to_close_topic / MAXIMUM_FLAGS_PER_POST
|
||||||
|
return if post_ids.count < min_post_required
|
||||||
|
|
||||||
|
# 3) now we have a maximum flow problem...
|
||||||
|
# the network will have
|
||||||
|
# - edges from the 'source' to each flaggers with a capacity of '# of flags casted by that user'
|
||||||
|
# - edges for each flags with a capacity of 1
|
||||||
|
# - edges from each posts to the 'sink' with a capacity of MAXIMUM_FLAGS_PER_POST
|
||||||
|
|
||||||
|
# first, we need to count the # of flags casted by each users
|
||||||
|
flags_casted_by_user = {}
|
||||||
|
flags.each { |flag| flags_casted_by_user[flag[0]] = flags.count { |f| f[0] == flag[0] } }
|
||||||
|
|
||||||
|
# then, we need to build a list of all the vertices
|
||||||
|
# ('source' being the first and 'sink" being the last)
|
||||||
|
index_of_user_id = {}
|
||||||
|
index_of_post_id = {}
|
||||||
|
|
||||||
|
user_ids.each_with_index { |user_id, index| index_of_user_id[user_id] = 1 + index }
|
||||||
|
post_ids.each_with_index { |post_id, index| index_of_post_id[post_id] = 1 + index + user_ids.count }
|
||||||
|
|
||||||
|
source = 0
|
||||||
|
sink = user_ids.count + post_ids.count + 1
|
||||||
|
n = sink + 1
|
||||||
|
|
||||||
|
# then, we need to build a map of all the edges (with their respective capacity)
|
||||||
|
# initially, everything is 0 (ie. no edge)
|
||||||
|
capacities = Array.new(n) { Array.new(n, 0) }
|
||||||
|
|
||||||
|
# from the 'source' -> all user_ids with a capacity of '# of flags casted by that user'
|
||||||
|
user_ids.each { |user_id| capacities[source][index_of_user_id[user_id]] = flags_casted_by_user[user_id] }
|
||||||
|
# for each pair (user_id, post_id) with a capacity of 1
|
||||||
|
flags.each { |f| capacities[index_of_user_id[f[0]]][index_of_post_id[f[1]]] = 1 }
|
||||||
|
# from each post_ids -> sink with a capacity of MAXIMUM_FLAGS_PER_POST
|
||||||
|
index_of_post_id.values.each { |i| capacities[i][sink] = MAXIMUM_FLAGS_PER_POST }
|
||||||
|
|
||||||
|
# finally, we use the 'relabel to front' algorithm to solve the maximum flow problem
|
||||||
|
maximum_flow = MaximumFlow.new.relabel_to_front(capacities, source, sink)
|
||||||
|
return if maximum_flow < SiteSetting.num_flags_to_close_topic
|
||||||
|
|
||||||
|
# 4) the threshold has been reached, we will close the topic waiting for intervention
|
||||||
|
message = I18n.t("temporarily_closed_due_to_flags")
|
||||||
|
topic.update_status("closed", true, Discourse.system_user, message)
|
||||||
|
end
|
||||||
|
|
||||||
def self.auto_hide_if_needed(acting_user, post, post_action_type)
|
def self.auto_hide_if_needed(acting_user, post, post_action_type)
|
||||||
return if post.hidden
|
return if post.hidden
|
||||||
|
|
||||||
|
|
|
@ -397,8 +397,8 @@ class Topic < ActiveRecord::Base
|
||||||
similar
|
similar
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_status(status, enabled, user)
|
def update_status(status, enabled, user, message=nil)
|
||||||
TopicStatusUpdate.new(self, user).update!(status, enabled)
|
TopicStatusUpdate.new(self, user).update!(status, enabled, message)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Atomically creates the next post number
|
# Atomically creates the next post number
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
TopicStatusUpdate = Struct.new(:topic, :user) do
|
TopicStatusUpdate = Struct.new(:topic, :user) do
|
||||||
def update!(status, enabled)
|
def update!(status, enabled, message=nil)
|
||||||
status = Status.new(status, enabled)
|
status = Status.new(status, enabled)
|
||||||
|
|
||||||
Topic.transaction do
|
Topic.transaction do
|
||||||
change(status)
|
change(status)
|
||||||
highest_post_number = topic.highest_post_number
|
highest_post_number = topic.highest_post_number
|
||||||
|
|
||||||
create_moderator_post_for(status)
|
create_moderator_post_for(status, message)
|
||||||
update_read_state_for(status, highest_post_number)
|
update_read_state_for(status, highest_post_number)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -31,8 +31,8 @@ TopicStatusUpdate = Struct.new(:topic, :user) do
|
||||||
CategoryFeaturedTopic.feature_topics_for(topic.category)
|
CategoryFeaturedTopic.feature_topics_for(topic.category)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_moderator_post_for(status)
|
def create_moderator_post_for(status, message=nil)
|
||||||
topic.add_moderator_post(user, message_for(status), options_for(status))
|
topic.add_moderator_post(user, message || message_for(status), options_for(status))
|
||||||
topic.reload
|
topic.reload
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -945,6 +945,9 @@ en:
|
||||||
max_age_unmatched_emails: "Delete unmatched screened email entries after (N) days."
|
max_age_unmatched_emails: "Delete unmatched screened email entries after (N) days."
|
||||||
max_age_unmatched_ips: "Delete unmatched screened IP entries after (N) days."
|
max_age_unmatched_ips: "Delete unmatched screened IP entries after (N) days."
|
||||||
|
|
||||||
|
num_flaggers_to_close_topic: "Minimum number of unique flaggers that is required to automatically pause a topic for intervention"
|
||||||
|
num_flags_to_close_topic: "Minimum number of active flags that is required to automatically pause a topic for intervention"
|
||||||
|
|
||||||
reply_by_email_enabled: "Enable replying to topics via email."
|
reply_by_email_enabled: "Enable replying to topics via email."
|
||||||
reply_by_email_address: "Template for reply by email incoming email address, for example: %{reply_key}@reply.example.com or replies+%{reply_key}@example.com"
|
reply_by_email_address: "Template for reply by email incoming email address, for example: %{reply_key}@reply.example.com or replies+%{reply_key}@example.com"
|
||||||
|
|
||||||
|
@ -1302,6 +1305,8 @@ en:
|
||||||
deferred: "Thanks for letting us know. We're looking into it."
|
deferred: "Thanks for letting us know. We're looking into it."
|
||||||
deferred_and_deleted: "Thanks for letting us know. We've removed the post."
|
deferred_and_deleted: "Thanks for letting us know. We've removed the post."
|
||||||
|
|
||||||
|
temporarily_closed_due_to_flags: "This topic is temporarily closed due to a large number of community flags"
|
||||||
|
|
||||||
system_messages:
|
system_messages:
|
||||||
post_hidden:
|
post_hidden:
|
||||||
subject_template: "Post hidden due to community flagging"
|
subject_template: "Post hidden due to community flagging"
|
||||||
|
|
|
@ -587,6 +587,8 @@ spam:
|
||||||
min_ban_entries_for_roll_up: 5
|
min_ban_entries_for_roll_up: 5
|
||||||
max_age_unmatched_emails: 365
|
max_age_unmatched_emails: 365
|
||||||
max_age_unmatched_ips: 365
|
max_age_unmatched_ips: 365
|
||||||
|
num_flaggers_to_close_topic: 5
|
||||||
|
num_flags_to_close_topic: 12
|
||||||
|
|
||||||
rate_limits:
|
rate_limits:
|
||||||
unique_posts_mins:
|
unique_posts_mins:
|
||||||
|
|
69
lib/maximum_flow.rb
Normal file
69
lib/maximum_flow.rb
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
# cf. http://en.wikipedia.org/wiki/Maximum_flow_problem
|
||||||
|
class MaximumFlow
|
||||||
|
|
||||||
|
# cf. http://en.wikipedia.org/wiki/Push%E2%80%93relabel_maximum_flow_algorithm
|
||||||
|
def relabel_to_front(capacities, source, sink)
|
||||||
|
n = capacities.length
|
||||||
|
flow = Array.new(n) { Array.new(n, 0) }
|
||||||
|
height = Array.new(n, 0)
|
||||||
|
excess = Array.new(n, 0)
|
||||||
|
seen = Array.new(n, 0)
|
||||||
|
queue = (0...n).select { |i| i != source && i != sink }.to_a
|
||||||
|
|
||||||
|
height[source] = n - 1
|
||||||
|
excess[source] = Float::INFINITY
|
||||||
|
(0...n).each { |v| push(source, v, capacities, flow, excess) }
|
||||||
|
|
||||||
|
p = 0
|
||||||
|
while p < queue.length
|
||||||
|
u = queue[p]
|
||||||
|
h = height[u]
|
||||||
|
discharge(u, capacities, flow, excess, seen, height, n)
|
||||||
|
if height[u] > h
|
||||||
|
queue.unshift(queue.delete_at(p))
|
||||||
|
p = 0
|
||||||
|
else
|
||||||
|
p += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
flow[source].reduce(:+)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def push(u, v, capacities, flow, excess)
|
||||||
|
residual_capacity = capacities[u][v] - flow[u][v]
|
||||||
|
send = [excess[u], residual_capacity].min
|
||||||
|
flow[u][v] += send
|
||||||
|
flow[v][u] -= send
|
||||||
|
excess[u] -= send
|
||||||
|
excess[v] += send
|
||||||
|
end
|
||||||
|
|
||||||
|
def discharge(u, capacities, flow, excess, seen, height, n)
|
||||||
|
while excess[u] > 0
|
||||||
|
if seen[u] < n
|
||||||
|
v = seen[u]
|
||||||
|
if capacities[u][v] - flow[u][v] > 0 && height[u] > height[v]
|
||||||
|
push(u, v, capacities, flow, excess)
|
||||||
|
else
|
||||||
|
seen[u] += 1
|
||||||
|
end
|
||||||
|
else
|
||||||
|
relabel(u, capacities, flow, height, n)
|
||||||
|
seen[u] = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def relabel(u, capacities, flow, height, n)
|
||||||
|
min_height = Float::INFINITY
|
||||||
|
(0...n).each do |v|
|
||||||
|
if capacities[u][v] - flow[u][v] > 0
|
||||||
|
min_height = [min_height, height[v]].min
|
||||||
|
height[u] = min_height + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -370,6 +370,55 @@ describe PostAction do
|
||||||
post.hidden.should == false
|
post.hidden.should == false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "will automatically close a topic due to large community flagging" do
|
||||||
|
SiteSetting.stubs(:flags_required_to_hide_post).returns(0)
|
||||||
|
SiteSetting.stubs(:num_flags_to_close_topic).returns(12)
|
||||||
|
SiteSetting.stubs(:num_flaggers_to_close_topic).returns(5)
|
||||||
|
|
||||||
|
topic = Fabricate(:topic)
|
||||||
|
post1 = create_post(topic: topic)
|
||||||
|
post2 = create_post(topic: topic)
|
||||||
|
post3 = create_post(topic: topic)
|
||||||
|
post4 = create_post(topic: topic)
|
||||||
|
|
||||||
|
flagger1 = Fabricate(:user)
|
||||||
|
flagger2 = Fabricate(:user)
|
||||||
|
flagger3 = Fabricate(:user)
|
||||||
|
flagger4 = Fabricate(:user)
|
||||||
|
flagger5 = Fabricate(:user)
|
||||||
|
|
||||||
|
# reaching `num_flaggers_to_close_topic` isn't enough
|
||||||
|
[flagger1, flagger2, flagger3, flagger4, flagger5].each do |flagger|
|
||||||
|
PostAction.act(flagger, post1, PostActionType.types[:inappropriate])
|
||||||
|
end
|
||||||
|
|
||||||
|
topic.reload.closed.should == false
|
||||||
|
|
||||||
|
# clean up
|
||||||
|
PostAction.where(post: post1).delete_all
|
||||||
|
|
||||||
|
# reaching `num_flags_to_close_topic` isn't enough
|
||||||
|
[flagger1, flagger2, flagger3].each do |flagger|
|
||||||
|
[post1, post2, post3, post4].each do |post|
|
||||||
|
PostAction.act(flagger, post, PostActionType.types[:inappropriate])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
topic.reload.closed.should == false
|
||||||
|
|
||||||
|
# clean up
|
||||||
|
PostAction.where(post: [post1, post2, post3, post4]).delete_all
|
||||||
|
|
||||||
|
# reaching both should close the topic
|
||||||
|
[flagger1, flagger2, flagger3, flagger4, flagger5].each do |flagger|
|
||||||
|
[post1, post2, post3, post4].each do |post|
|
||||||
|
PostAction.act(flagger, post, PostActionType.types[:inappropriate])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
topic.reload.closed.should == true
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "prevents user to act twice at the same time" do
|
it "prevents user to act twice at the same time" do
|
||||||
|
|
Loading…
Reference in a new issue