discourse/lib/user_comm_screener.rb
Martin Brennan 3f1024de76
DEV: Refactor DM channel creation into new service pattern (#22144)
This will be used when we move the channel creation for DMs
to happen when we first send a message in a DM channel to avoid
a double-request. For now we can just have a new API endpoint
for creating this that the existing frontend code can use,
that uses the new service pattern.

This also uses the new policy pattern for services where the policy
can be defined in a class so a more dynamic reason for the policy
failing can be sent to the controller.

Co-authored-by: Loïc Guitaut <loic@discourse.org>
2023-07-03 10:18:37 +10:00

320 lines
11 KiB
Ruby

# frozen_string_literal: true
# There are various ways within Discourse that a user can prevent
# other users communicating with them. The purpose of this class is to
# find which of the target users are ignoring, muting, or preventing
# private messages from the acting user, so we can take alternative
# action (such as raising an error or showing a helpful message) if so.
#
# Users may Mute another user (the actor), which will:
#
# * Prevent PMs from the actor
# * Prevent notifications from the actor
#
# Users may Ignore another user (the actor), which will:
#
# * Do everything that Mute does as well as suppressing content made by
# the actor (such as posts) from the UI
#
# Users may also either:
#
# a) disallow PMs from being sent to them or
# b) disallow PMs except from a certain allowlist of users
#
# A user may have this preference but have no Muted or Ignored users, which
# necessitates the difference between methods in this class.
#
# An important note is that **all of these settings do not apply when the actor
# is a staff member**. So admins and moderators can PM and notify anyone they please.
#
# The secondary usage of this class is to determine which users the actor themselves
# are muting, ignoring, or preventing private messages from. This is useful when
# wanting to alert the actor to these users in the UI in various ways, or prevent
# the actor from communicating with users they prefer not to talk with.
class UserCommScreener
attr_reader :acting_user, :preferences
class UserCommPref
attr_accessor :user_id,
:is_muting,
:is_ignoring,
:is_disallowing_all_pms,
:is_disallowing_pms_from_acting_user
def initialize(preferences)
@user_id = preferences[:user_id]
@is_muting = preferences[:is_muting]
@is_ignoring = preferences[:is_ignoring]
@is_disallowing_all_pms = preferences[:is_disallowing_all_pms]
@is_disallowing_pms_from_acting_user = preferences[:is_disallowing_pms_from_acting_user]
end
def communication_prevented?
ignoring_or_muting? || disallowing_pms?
end
def ignoring_or_muting?
is_muting || is_ignoring
end
def disallowing_pms?
is_disallowing_all_pms || is_disallowing_pms_from_acting_user
end
end
UserCommPrefs =
Struct.new(:acting_user, :user_preference_map) do
def acting_user_staff?
acting_user.staff?
end
def user_ids
user_preference_map.keys
end
def for_user(user_id)
user_preference_map[user_id]
end
def allowing_actor_communication
return user_preference_map.values if acting_user_staff?
user_preference_map.reject { |user_id, pref| pref.communication_prevented? }.values
end
def preventing_actor_communication
return [] if acting_user_staff?
user_preference_map.select { |user_id, pref| pref.communication_prevented? }.values
end
def ignoring_or_muting?(user_id)
return false if acting_user_staff?
pref = for_user(user_id)
pref.present? && pref.ignoring_or_muting?
end
def disallowing_pms?(user_id)
return false if acting_user_staff?
pref = for_user(user_id)
pref.present? && pref.disallowing_pms?
end
end
private_constant :UserCommPref
private_constant :UserCommPrefs
def initialize(acting_user: nil, acting_user_id: nil, target_user_ids:)
raise ArgumentError if acting_user.blank? && acting_user_id.blank?
@acting_user = acting_user.present? ? acting_user : User.find(acting_user_id)
target_user_ids = Array.wrap(target_user_ids) - [@acting_user.id]
@target_users = User.where(id: target_user_ids).pluck(:id, :username).to_h
@preferences = load_preference_map
end
##
# Users who have preferences are the only ones initially loaded by the query,
# so implicitly the leftover users have no preferences that mute, ignore,
# or disallow PMs from any other user.
def allowing_actor_communication
(preferences.allowing_actor_communication.map(&:user_id) + users_with_no_preference).uniq
end
##
# Any users who are either ignoring, muting, or disallowing PMs from the actor.
# Ignoring and muting implicitly ignore PMs which is why they fall under this
# umbrella as well.
def preventing_actor_communication
preferences.preventing_actor_communication.map(&:user_id)
end
##
# Whether the user is ignoring or muting the actor, meaning the actor cannot
# PM or send notifications to this target user.
def ignoring_or_muting_actor?(user_id)
validate_user_id!(user_id)
preferences.ignoring_or_muting?(user_id)
end
##
# Whether the user is disallowing PMs from the actor specifically or in general,
# meaning the actor cannot send PMs to this target user. Ignoring or muting
# implicitly disallows PMs, so we need to take into account those preferences
# here too.
def disallowing_pms_from_actor?(user_id)
validate_user_id!(user_id)
preferences.disallowing_pms?(user_id) || ignoring_or_muting_actor?(user_id)
end
def actor_allowing_communication
@target_users.keys - actor_preventing_communication
end
def actor_preventing_communication
(
actor_preferences[:ignoring] + actor_preferences[:muting] +
actor_preferences[:disallowed_pms_from]
).uniq
end
##
# The actor methods below are more fine-grained than the user ones,
# since we may want to display more detailed messages to the actor about
# their preferences than we do when we are informing the actor that
# they cannot communicate with certain users.
#
# In this spirit, actor_disallowing_pms? is intentionally different from
# disallowing_pms_from_actor? above.
def actor_ignoring?(user_id)
validate_user_id!(user_id)
actor_preferences[:ignoring].include?(user_id)
end
def actor_muting?(user_id)
validate_user_id!(user_id)
actor_preferences[:muting].include?(user_id)
end
def actor_disallowing_pms?(user_id)
validate_user_id!(user_id)
return true if actor_disallowing_all_pms?
return false if !acting_user.user_option.enable_allowed_pm_users
actor_preferences[:disallowed_pms_from].include?(user_id)
end
def actor_disallowing_any_pms?(user_ids)
user_ids.any? { |user_id| actor_disallowing_pms?(user_id) }
end
def actor_disallowing_all_pms?
!acting_user.user_option.allow_private_messages
end
private
def users_with_no_preference
@target_users.keys - @preferences.user_ids
end
def load_preference_map
resolved_user_communication_preferences = {}
# Since noone can prevent staff communicating with them there is no
# need to load their preferences.
if @acting_user.staff?
return UserCommPrefs.new(acting_user, resolved_user_communication_preferences)
end
# Add all users who have muted or ignored the acting user, or have
# disabled PMs from them or anyone at all.
user_communication_preferences.each do |user|
resolved_user_communication_preferences[user.id] = UserCommPref.new(
user_id: user.id,
is_muting: user.is_muting,
is_ignoring: user.is_ignoring,
is_disallowing_all_pms: user.is_disallowing_all_pms,
is_disallowing_pms_from_acting_user: false,
)
end
# If any of the users has allowed_pm_users enabled check to see if the creator
# is in their list.
users_with_allowed_pms = user_communication_preferences.select(&:enable_allowed_pm_users)
if users_with_allowed_pms.any?
user_ids_with_allowed_pms = users_with_allowed_pms.map(&:id)
user_ids_acting_can_pm =
AllowedPmUser
.where(allowed_pm_user_id: acting_user.id, user_id: user_ids_with_allowed_pms)
.pluck(:user_id)
.uniq
# If not in the list mark them as not accepting communication.
user_ids_acting_cannot_pm = user_ids_with_allowed_pms - user_ids_acting_can_pm
user_ids_acting_cannot_pm.each do |user_id|
if resolved_user_communication_preferences[user_id]
resolved_user_communication_preferences[user_id].is_disallowing_pms_from_acting_user =
true
else
resolved_user_communication_preferences[user_id] = UserCommPref.new(
user_id: user_id,
is_muting: false,
is_ignoring: false,
is_disallowing_all_pms: false,
is_disallowing_pms_from_acting_user: true,
)
end
end
end
UserCommPrefs.new(acting_user, resolved_user_communication_preferences)
end
def actor_preferences
@actor_preferences ||=
begin
user_ids_by_preference_type =
actor_communication_preferences.reduce({}) do |hash, pref|
hash[pref.preference_type] ||= []
hash[pref.preference_type] << pref.target_user_id
hash
end
disallowed_pms_from =
if acting_user.user_option.enable_allowed_pm_users
(user_ids_by_preference_type["disallowed_pm"] || [])
else
[]
end
{
muting: user_ids_by_preference_type["muted"] || [],
ignoring: user_ids_by_preference_type["ignored"] || [],
disallowed_pms_from: disallowed_pms_from,
}
end
end
def user_communication_preferences
@user_communication_preferences ||=
DB.query(<<~SQL, acting_user_id: acting_user.id, target_user_ids: @target_users.keys)
SELECT users.id,
CASE WHEN muted_users.muted_user_id IS NOT NULL THEN true ELSE false END AS is_muting,
CASE WHEN ignored_users.ignored_user_id IS NOT NULL THEN true ELSE false END AS is_ignoring,
CASE WHEN user_options.allow_private_messages THEN false ELSE true END AS is_disallowing_all_pms,
user_options.enable_allowed_pm_users
FROM users
LEFT JOIN user_options ON user_options.user_id = users.id
LEFT JOIN muted_users ON muted_users.user_id = users.id AND muted_users.muted_user_id = :acting_user_id
LEFT JOIN ignored_users ON ignored_users.user_id = users.id AND ignored_users.ignored_user_id = :acting_user_id
WHERE users.id IN (:target_user_ids) AND
(
NOT user_options.allow_private_messages OR
user_options.enable_allowed_pm_users OR
muted_users.user_id IS NOT NULL OR
ignored_users.user_id IS NOT NULL
)
SQL
end
def actor_communication_preferences
@actor_communication_preferences ||=
DB.query(<<~SQL, acting_user_id: acting_user.id, target_user_ids: @target_users.keys)
SELECT users.id AS target_user_id, 'disallowed_pm' AS preference_type FROM users
LEFT JOIN allowed_pm_users ON allowed_pm_users.allowed_pm_user_id = users.id
WHERE users.id IN (:target_user_ids)
AND (allowed_pm_users.user_id = :acting_user_id OR allowed_pm_users.user_id IS NULL)
AND allowed_pm_users.allowed_pm_user_id IS NULL
UNION
SELECT ignored_user_id AS target_user_id, 'ignored' AS preference_type
FROM ignored_users
WHERE user_id = :acting_user_id AND ignored_user_id IN (:target_user_ids)
UNION
SELECT muted_user_id AS target_user_id, 'muted' AS preference_type
FROM muted_users
WHERE user_id = :acting_user_id AND muted_user_id IN (:target_user_ids)
SQL
end
def validate_user_id!(user_id)
return if user_id == acting_user.id
raise Discourse::NotFound if !@target_users.keys.include?(user_id)
end
end