FIX: remove complicated 'chat_duplicate_message_sensitivity' site setting (#30516)

And change the "formula" to check for duplicate messages to

- no duplicate check in 1:1 DMs
- only duplicate check in group DMs / channels, for posts made by the
same user, in the past 10 seconds

Internal ref - t/144262
This commit is contained in:
Régis Hanol 2025-01-13 12:32:51 +01:00 committed by GitHub
parent d7aa13328d
commit 1f483f48a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 124 additions and 165 deletions

View File

@ -2,45 +2,26 @@
module Chat module Chat
class DuplicateMessageValidator class DuplicateMessageValidator
attr_reader :chat_message attr_reader :chat_message, :chat_channel
def initialize(chat_message) def initialize(chat_message)
@chat_message = chat_message @chat_message = chat_message
@chat_channel = chat_message&.chat_channel
end end
def validate def validate
return if SiteSetting.chat_duplicate_message_sensitivity.zero? return if chat_message.nil? || chat_channel.nil?
matrix = return if chat_message.user.bot?
DuplicateMessageValidator.sensitivity_matrix(SiteSetting.chat_duplicate_message_sensitivity) return if chat_channel.direct_message_channel? && chat_channel.user_count <= 2
# Check if the length of the message is too short to check for a duplicate message if chat_channel
return if chat_message.message.length < matrix[:min_message_length]
# Check if there are enough users in the channel to check for a duplicate message
return if (chat_message.chat_channel.user_count || 0) < matrix[:min_user_count]
# Check if the same duplicate message has been posted in the last N seconds by any user
if !chat_message
.chat_channel
.chat_messages .chat_messages
.where("created_at > ?", matrix[:min_past_seconds].seconds.ago) .where(created_at: 10.seconds.ago..)
.where(message: chat_message.message) .where("LOWER(message) = ?", chat_message.message.strip.downcase)
.where(user: chat_message.user)
.exists? .exists?
return chat_message.errors.add(:base, I18n.t("chat.errors.duplicate_message"))
end end
chat_message.errors.add(:base, I18n.t("chat.errors.duplicate_message"))
end
def self.sensitivity_matrix(sensitivity)
{
# 0.1 sensitivity = 100 users and 1.0 sensitivity = 5 users.
min_user_count: (-1.0 * 105.5 * sensitivity + 110.55).to_i,
# 0.1 sensitivity = 30 chars and 1.0 sensitivity = 10 chars.
min_message_length: (-1.0 * 22.2 * sensitivity + 32.22).to_i,
# 0.1 sensitivity = 10 seconds and 1.0 sensitivity = 60 seconds.
min_past_seconds: (55.55 * sensitivity + 4.5).to_i,
}
end end
end end
end end

View File

@ -54,6 +54,10 @@ Fabricator(:direct_message_channel, from: :chat_channel) do
end end
end end
def fake_chat_message
Faker::Alphanumeric.alpha(number: [15, SiteSetting.chat_minimum_message_length].max)
end
Fabricator(:chat_message, class_name: "Chat::Message") do Fabricator(:chat_message, class_name: "Chat::Message") do
transient use_service: false transient use_service: false
@ -68,7 +72,7 @@ end
Fabricator(:chat_message_without_service, class_name: "Chat::Message") do Fabricator(:chat_message_without_service, class_name: "Chat::Message") do
user user
chat_channel chat_channel
message { Faker::Alphanumeric.alpha(number: SiteSetting.chat_minimum_message_length) } message { fake_chat_message }
after_build { |message, attrs| message.cook } after_build { |message, attrs| message.cook }
after_create { |message, attrs| message.upsert_mentions } after_create { |message, attrs| message.upsert_mentions }
@ -96,9 +100,7 @@ Fabricator(:chat_message_with_service, class_name: "Chat::CreateMessage") do
resolved_class.call( resolved_class.call(
params: { params: {
chat_channel_id: channel.id, chat_channel_id: channel.id,
message: message: transients[:message].presence || fake_chat_message,
transients[:message] ||
Faker::Alphanumeric.alpha(number: SiteSetting.chat_minimum_message_length),
thread_id: transients[:thread]&.id, thread_id: transients[:thread]&.id,
in_reply_to_id: transients[:in_reply_to]&.id, in_reply_to_id: transients[:in_reply_to]&.id,
upload_ids: transients[:upload_ids], upload_ids: transients[:upload_ids],

View File

@ -1,121 +1,108 @@
# frozen_string_literal: true # frozen_string_literal: true
describe Chat::DuplicateMessageValidator do describe Chat::DuplicateMessageValidator do
let(:chat_channel) { Fabricate(:chat_channel) } let(:message) { "goal!" }
fab!(:category_channel) { Fabricate(:chat_channel) }
fab!(:dm_channel) { Fabricate(:direct_message_channel) }
fab!(:user)
def message_blocked?(message) def message_blocked?(message:, chat_channel:, user:)
chat_message = Fabricate.build(:chat_message, message: message, chat_channel: chat_channel) chat_message = Fabricate.build(:chat_message, user:, message:, chat_channel:)
described_class.new(chat_message).validate described_class.new(chat_message).validate
chat_message.errors.full_messages.include?(I18n.t("chat.errors.duplicate_message")) chat_message.errors.full_messages.include?(I18n.t("chat.errors.duplicate_message"))
end end
it "adds no errors when chat_duplicate_message_sensitivity is 0" do it "blocks a message if it was posted in a category channel in the last 10 seconds by the same user" do
SiteSetting.chat_duplicate_message_sensitivity = 0 Fabricate(
expect(message_blocked?("test")).to eq(false) :chat_message,
created_at: 1.second.ago,
user:,
message:,
chat_channel: category_channel,
)
expect(message_blocked?(message:, user:, chat_channel: category_channel)).to eq(true)
end end
skip "errors if the message meets the requirements for sensitivity 0.1" do it "doesn't block a message if it's different" do
SiteSetting.chat_duplicate_message_sensitivity = 0.1 Fabricate(
:chat_message,
created_at: 1.second.ago,
user:,
message:,
chat_channel: category_channel,
)
chat_channel.update!(user_count: 100) expect(message_blocked?(message: "BUT!", user:, chat_channel: category_channel)).to eq(false)
message = "this is a 30 char message for test"
dupe =
Fabricate(
:chat_message,
created_at: 1.second.ago,
message: message,
chat_channel: chat_channel,
)
expect(message_blocked?(message)).to eq(true)
expect(message_blocked?("blah")).to eq(false)
dupe.update!(created_at: 11.seconds.ago)
expect(message_blocked?(message)).to eq(false)
end end
skip "errors if the message meets the requirements for sensitivity 0.5" do it "doesn't block a message if it was posted more than 10 seconds ago" do
SiteSetting.chat_duplicate_message_sensitivity = 0.5 Fabricate(
chat_channel.update!(user_count: 57) :chat_message,
message = "this is a 21 char msg" created_at: 11.seconds.ago,
dupe = user:,
Fabricate( message:,
:chat_message, chat_channel: category_channel,
created_at: 1.second.ago, )
message: message,
chat_channel: chat_channel,
)
expect(message_blocked?(message)).to eq(true)
expect(message_blocked?("blah")).to eq(false) expect(message_blocked?(message:, user:, chat_channel: category_channel)).to eq(false)
dupe.update!(created_at: 33.seconds.ago)
expect(message_blocked?(message)).to eq(false)
end end
skip "errors if the message meets the requirements for sensitivity 1.0" do it "blocks a message case insensitively" do
SiteSetting.chat_duplicate_message_sensitivity = 1.0 Fabricate(
chat_channel.update!(user_count: 5) :chat_message,
message = "10 char msg" created_at: 1.second.ago,
dupe = user:,
Fabricate( message:,
:chat_message, chat_channel: category_channel,
created_at: 1.second.ago, )
message: message,
chat_channel: chat_channel,
)
expect(message_blocked?(message)).to eq(true)
expect(message_blocked?("blah")).to eq(false) expect(message_blocked?(message: message.upcase, user:, chat_channel: category_channel)).to eq(
true,
dupe.update!(created_at: 61.seconds.ago) )
expect(message_blocked?(message)).to eq(false)
end end
describe "#sensitivity_matrix" do it "doesn't block a message if it was posted by a different user" do
describe "#min_user_count" do Fabricate(
it "calculates correctly for each of the major points from 0.1 to 1.0" do :chat_message,
expect(described_class.sensitivity_matrix(0.1)[:min_user_count]).to eq(100) created_at: 1.second.ago,
expect(described_class.sensitivity_matrix(0.2)[:min_user_count]).to eq(89) user: Fabricate(:user),
expect(described_class.sensitivity_matrix(0.3)[:min_user_count]).to eq(78) message:,
expect(described_class.sensitivity_matrix(0.4)[:min_user_count]).to eq(68) chat_channel: category_channel,
expect(described_class.sensitivity_matrix(0.5)[:min_user_count]).to eq(57) )
expect(described_class.sensitivity_matrix(0.6)[:min_user_count]).to eq(47)
expect(described_class.sensitivity_matrix(0.7)[:min_user_count]).to eq(36)
expect(described_class.sensitivity_matrix(0.8)[:min_user_count]).to eq(26)
expect(described_class.sensitivity_matrix(0.9)[:min_user_count]).to eq(15)
expect(described_class.sensitivity_matrix(1.0)[:min_user_count]).to eq(5)
end
end
describe "#min_message_length" do expect(message_blocked?(message:, user:, chat_channel: category_channel)).to eq(false)
it "calculates correctly for each of the major points from 0.1 to 1.0" do end
expect(described_class.sensitivity_matrix(0.1)[:min_message_length]).to eq(30)
expect(described_class.sensitivity_matrix(0.2)[:min_message_length]).to eq(27)
expect(described_class.sensitivity_matrix(0.3)[:min_message_length]).to eq(25)
expect(described_class.sensitivity_matrix(0.4)[:min_message_length]).to eq(23)
expect(described_class.sensitivity_matrix(0.5)[:min_message_length]).to eq(21)
expect(described_class.sensitivity_matrix(0.6)[:min_message_length]).to eq(18)
expect(described_class.sensitivity_matrix(0.7)[:min_message_length]).to eq(16)
expect(described_class.sensitivity_matrix(0.8)[:min_message_length]).to eq(14)
expect(described_class.sensitivity_matrix(0.9)[:min_message_length]).to eq(12)
expect(described_class.sensitivity_matrix(1.0)[:min_message_length]).to eq(10)
end
end
describe "#min_past_seconds" do it "doesn't block a message if it was posted in a different channel" do
it "calculates correctly for each of the major points from 0.1 to 1.0" do Fabricate(
expect(described_class.sensitivity_matrix(0.1)[:min_past_seconds]).to eq(10) :chat_message,
expect(described_class.sensitivity_matrix(0.2)[:min_past_seconds]).to eq(15) created_at: 1.second.ago,
expect(described_class.sensitivity_matrix(0.3)[:min_past_seconds]).to eq(21) user:,
expect(described_class.sensitivity_matrix(0.4)[:min_past_seconds]).to eq(26) message:,
expect(described_class.sensitivity_matrix(0.5)[:min_past_seconds]).to eq(32) chat_channel: Fabricate(:chat_channel),
expect(described_class.sensitivity_matrix(0.6)[:min_past_seconds]).to eq(37) )
expect(described_class.sensitivity_matrix(0.7)[:min_past_seconds]).to eq(43)
expect(described_class.sensitivity_matrix(0.8)[:min_past_seconds]).to eq(48) expect(message_blocked?(message:, user:, chat_channel: category_channel)).to eq(false)
expect(described_class.sensitivity_matrix(0.9)[:min_past_seconds]).to eq(54) end
expect(described_class.sensitivity_matrix(1.0)[:min_past_seconds]).to eq(60)
end it "doesn't block a message if it was posted by a bot" do
end bot = Fabricate(:bot)
Fabricate(
:chat_message,
created_at: 1.second.ago,
user: bot,
message:,
chat_channel: category_channel,
)
expect(message_blocked?(message:, user: bot, chat_channel: category_channel)).to eq(false)
end
it "doesn't block a message if it was posted in a 1:1 DM" do
Fabricate(:chat_message, created_at: 1.second.ago, user:, message:, chat_channel: dm_channel)
expect(message_blocked?(message:, user:, chat_channel: dm_channel)).to eq(false)
end end
end end

View File

@ -616,18 +616,15 @@ describe Chat::Message do
end end
describe "blocking duplicate messages" do describe "blocking duplicate messages" do
fab!(:channel) { Fabricate(:chat_channel, user_count: 10) } let(:message) { "this is duplicate" }
fab!(:user1) { Fabricate(:user) } fab!(:chat_channel)
fab!(:user2) { Fabricate(:user) } fab!(:user)
before { SiteSetting.chat_duplicate_message_sensitivity = 1 } it "blocks duplicate messages" do
Fabricate(:chat_message, message:, chat_channel:, user:)
it "blocks duplicate messages for the message, channel user, and message age requirements" do msg = described_class.new(message:, chat_channel:, user:)
Fabricate(:chat_message, message: "this is duplicate", chat_channel: channel, user: user1) msg.valid?
message = expect(msg.errors.full_messages).to include(I18n.t("chat.errors.duplicate_message"))
described_class.new(message: "this is duplicate", chat_channel: channel, user: user2)
message.valid?
expect(message.errors.full_messages).to include(I18n.t("chat.errors.duplicate_message"))
end end
end end

View File

@ -43,7 +43,7 @@ module ChatSystemHelpers
chat_channel_id: channel.id, chat_channel_id: channel.id,
in_reply_to_id: in_reply_to, in_reply_to_id: in_reply_to,
thread_id: thread_id, thread_id: thread_id,
message: Faker::Alphanumeric.alpha(number: SiteSetting.chat_minimum_message_length), message: fake_chat_message,
}, },
) )
@ -68,6 +68,10 @@ module ChatSpecHelpers
) )
end end
def fake_chat_message
Faker::Alphanumeric.alpha(number: [15, SiteSetting.chat_minimum_message_length].max)
end
def update_message!(message, text: nil, user: Discourse.system_user, upload_ids: nil) def update_message!(message, text: nil, user: Discourse.system_user, upload_ids: nil)
Chat::UpdateMessage.call( Chat::UpdateMessage.call(
guardian: user.guardian, guardian: user.guardian,

View File

@ -22,7 +22,6 @@ RSpec.describe Chat::Api::ChannelsArchivesController do
before do before do
SiteSetting.chat_enabled = true SiteSetting.chat_enabled = true
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
SiteSetting.chat_duplicate_message_sensitivity = 0
end end
describe "#create" do describe "#create" do

View File

@ -6,7 +6,6 @@ RSpec.describe Chat::Api::ChannelsStatusController do
before do before do
SiteSetting.chat_enabled = true SiteSetting.chat_enabled = true
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
SiteSetting.chat_duplicate_message_sensitivity = 0
end end
def status(status) def status(status)

View File

@ -40,7 +40,6 @@ RSpec.describe Chat::UpdateMessage do
before do before do
SiteSetting.chat_enabled = true SiteSetting.chat_enabled = true
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
SiteSetting.chat_duplicate_message_sensitivity = 0
Jobs.run_immediately! Jobs.run_immediately!
[admin1, admin2, user1, user2, user3, user4].each { |user| public_chat_channel.add(user) } [admin1, admin2, user1, user2, user3, user4].each { |user| public_chat_channel.add(user) }
@ -580,23 +579,14 @@ RSpec.describe Chat::UpdateMessage do
fab!(:upload1) { Fabricate(:upload, user: user1) } fab!(:upload1) { Fabricate(:upload, user: user1) }
fab!(:upload2) { Fabricate(:upload, user: user1) } fab!(:upload2) { Fabricate(:upload, user: user1) }
before do
SiteSetting.chat_duplicate_message_sensitivity = 1.0
public_chat_channel.update!(user_count: 50)
end
it "errors when editing the message to be the same as one that was posted recently" do it "errors when editing the message to be the same as one that was posted recently" do
chat_message_1 = chat_message_1 =
create_chat_message(user1, "this is some chat message", public_chat_channel) create_chat_message(user1, "this is some chat message", public_chat_channel)
chat_message_2 = chat_message_2 =
create_chat_message( create_chat_message(user1, "another different chat message here", public_chat_channel)
Fabricate(:user),
"another different chat message here",
public_chat_channel,
)
chat_message_1.update!(created_at: 30.seconds.ago) chat_message_1.update!(created_at: 15.seconds.ago)
chat_message_2.update!(created_at: 20.seconds.ago) chat_message_2.update!(created_at: 5.seconds.ago)
expect do expect do
described_class.call( described_class.call(
@ -619,7 +609,7 @@ RSpec.describe Chat::UpdateMessage do
public_chat_channel, public_chat_channel,
upload_ids: [upload1.id, upload2.id], upload_ids: [upload1.id, upload2.id],
) )
chat_message.update!(created_at: 30.seconds.ago) chat_message.update!(created_at: 5.seconds.ago)
updater = updater =
described_class.call( described_class.call(

View File

@ -12,8 +12,8 @@ RSpec.describe "Message notifications - mobile", type: :system, mobile: true do
chat_system_bootstrap chat_system_bootstrap
end end
def create_message(channel, text: "this is fine", user: Fabricate(:user)) def create_message(chat_channel, message: nil, user: Fabricate(:user))
Fabricate(:chat_message_with_service, chat_channel: channel, message: text, user: user) Fabricate(:chat_message_with_service, chat_channel:, message:, user:)
end end
context "as a user" do context "as a user" do
@ -98,7 +98,7 @@ RSpec.describe "Message notifications - mobile", type: :system, mobile: true do
create_message( create_message(
channel_1, channel_1,
user: user_1, user: user_1,
text: "hello @#{current_user.username} what's up?", message: "hello @#{current_user.username} what's up?",
) )
expect(page).to have_css(".chat-header-icon .chat-channel-unread-indicator") expect(page).to have_css(".chat-header-icon .chat-channel-unread-indicator")
@ -113,7 +113,7 @@ RSpec.describe "Message notifications - mobile", type: :system, mobile: true do
create_message( create_message(
channel_1, channel_1,
user: user_1, user: user_1,
text: "Are you busy @#{current_user.username}?", message: "Are you busy @#{current_user.username}?",
) )
3.times { create_message(channel_1, user: user_1) } 3.times { create_message(channel_1, user: user_1) }

View File

@ -116,7 +116,7 @@ module PageObjects
end end
def send_message(text = nil) def send_message(text = nil)
text ||= Faker::Lorem.characters(number: SiteSetting.chat_minimum_message_length) text ||= fake_chat_message
text = text.chomp if text.present? # having \n on the end of the string counts as an Enter keypress text = text.chomp if text.present? # having \n on the end of the string counts as an Enter keypress
composer.fill_in(with: text) composer.fill_in(with: text)
click_send_message click_send_message

View File

@ -96,7 +96,7 @@ module PageObjects
end end
def send_message(text = nil) def send_message(text = nil)
text ||= Faker::Lorem.characters(number: SiteSetting.chat_minimum_message_length) text ||= fake_chat_message
text = text.chomp if text.present? # having \n on the end of the string counts as an Enter keypress text = text.chomp if text.present? # having \n on the end of the string counts as an Enter keypress
composer.fill_in(with: text) composer.fill_in(with: text)
click_send_message click_send_message