# frozen_string_literal: true ## # When we are attempting to notify users based on a message we have to take # into account the following: # # * Individual user mentions like @alfred # * Group mentions that include N users such as @support # * Global @here and @all mentions # * Users watching the channel via UserChatChannelMembership # # For various reasons a mention may not notify a user: # # * The target user of the mention is ignoring or muting the user who created the message # * The target user either cannot chat or cannot see the chat channel, in which case # they are defined as `unreachable` # * The target user is not a member of the channel, in which case they are defined # as `welcome_to_join` # * In the case of global @here and @all mentions users with the preference # `ignore_channel_wide_mention` set to true will not be notified # # For any users that fall under the `unreachable` or `welcome_to_join` umbrellas # we send a MessageBus message to the UI and to inform the creating user. The # creating user can invite any `welcome_to_join` users to the channel. Target # users who are ignoring or muting the creating user _do not_ fall into this bucket. # # The ignore/mute filtering is also applied via the ChatNotifyWatching job, # which prevents desktop / push notifications being sent. class Chat::ChatNotifier class << self def user_has_seen_message?(membership, chat_message_id) (membership.last_read_message_id || 0) >= chat_message_id end def push_notification_tag(type, chat_channel_id) "#{Discourse.current_hostname}-chat-#{type}-#{chat_channel_id}" end def notify_edit(chat_message:, timestamp:) Jobs.enqueue( :send_message_notifications, chat_message_id: chat_message.id, timestamp: timestamp.iso8601(6), reason: "edit", ) end def notify_new(chat_message:, timestamp:) Jobs.enqueue( :send_message_notifications, chat_message_id: chat_message.id, timestamp: timestamp.iso8601(6), reason: "new", ) end end def initialize(chat_message, timestamp) @chat_message = chat_message @timestamp = timestamp @chat_channel = @chat_message.chat_channel @user = @chat_message.user end ### Public API def notify_new to_notify = list_users_to_notify mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids] mentioned_user_ids.each do |member_id| ChatPublisher.publish_new_mention(member_id, @chat_channel.id, @chat_message.id) end notify_creator_of_inaccessible_mentions(to_notify) notify_mentioned_users(to_notify) notify_watching_users(except: mentioned_user_ids << @user.id) to_notify end def notify_edit existing_notifications = ChatMention.includes(:user, :notification).where(chat_message: @chat_message) already_notified_user_ids = existing_notifications.map(&:user_id) to_notify = list_users_to_notify mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids] needs_deletion = already_notified_user_ids - mentioned_user_ids needs_deletion.each do |user_id| chat_mention = existing_notifications.detect { |n| n.user_id == user_id } chat_mention.notification.destroy! chat_mention.destroy! end needs_notification_ids = mentioned_user_ids - already_notified_user_ids return if needs_notification_ids.blank? notify_creator_of_inaccessible_mentions(to_notify) notify_mentioned_users(to_notify, already_notified_user_ids: already_notified_user_ids) to_notify end private def list_users_to_notify direct_mentions_count = direct_mentions_from_cooked.length group_mentions_count = group_name_mentions.length skip_notifications = (direct_mentions_count + group_mentions_count) > SiteSetting.max_mentions_per_chat_message {}.tap do |to_notify| # The order of these methods is the precedence # between different mention types. already_covered_ids = [] expand_direct_mentions(to_notify, already_covered_ids, skip_notifications) expand_group_mentions(to_notify, already_covered_ids, skip_notifications) expand_here_mention(to_notify, already_covered_ids, skip_notifications) expand_global_mention(to_notify, already_covered_ids, skip_notifications) filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids) to_notify[:all_mentioned_user_ids] = already_covered_ids end end def chat_users User .includes(:user_chat_channel_memberships, :group_users) .distinct .joins("LEFT OUTER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id") .joins(:user_option) .real .not_suspended .where(user_options: { chat_enabled: true }) .where.not(username_lower: @user.username.downcase) end def rest_of_the_channel chat_users.where( user_chat_channel_memberships: { following: true, chat_channel_id: @chat_channel.id, }, ) end def members_accepting_channel_wide_notifications rest_of_the_channel.where(user_options: { ignore_channel_wide_mention: [false, nil] }) end def direct_mentions_from_cooked @direct_mentions_from_cooked ||= Nokogiri::HTML5.fragment(@chat_message.cooked).css(".mention").map(&:text) end def normalized_mentions(mentions) mentions.reduce([]) do |memo, mention| %w[@here @all].include?(mention.downcase) ? memo : (memo << mention[1..-1].downcase) end end def expand_global_mention(to_notify, already_covered_ids, skip) typed_global_mention = direct_mentions_from_cooked.include?("@all") if typed_global_mention && @chat_channel.allow_channel_wide_mentions && !skip to_notify[:global_mentions] = members_accepting_channel_wide_notifications .where.not(username_lower: normalized_mentions(direct_mentions_from_cooked)) .where.not(id: already_covered_ids) .pluck(:id) already_covered_ids.concat(to_notify[:global_mentions]) else to_notify[:global_mentions] = [] end end def expand_here_mention(to_notify, already_covered_ids, skip) typed_here_mention = direct_mentions_from_cooked.include?("@here") if typed_here_mention && @chat_channel.allow_channel_wide_mentions && !skip to_notify[:here_mentions] = members_accepting_channel_wide_notifications .where("last_seen_at > ?", 5.minutes.ago) .where.not(username_lower: normalized_mentions(direct_mentions_from_cooked)) .where.not(id: already_covered_ids) .pluck(:id) already_covered_ids.concat(to_notify[:here_mentions]) else to_notify[:here_mentions] = [] end end def group_users_to_notify(users) potential_participants, unreachable = users.partition do |user| guardian = Guardian.new(user) guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel) end participants, welcome_to_join = potential_participants.partition do |participant| participant.user_chat_channel_memberships.any? do |m| predicate = m.chat_channel_id == @chat_channel.id predicate = predicate && m.following == true if @chat_channel.public_channel? predicate end end { already_participating: participants || [], welcome_to_join: welcome_to_join || [], unreachable: unreachable || [], } end def expand_direct_mentions(to_notify, already_covered_ids, skip) if skip direct_mentions = [] else direct_mentions = chat_users .where(username_lower: normalized_mentions(direct_mentions_from_cooked)) .where.not(id: already_covered_ids) end grouped = group_users_to_notify(direct_mentions) to_notify[:direct_mentions] = grouped[:already_participating].map(&:id) to_notify[:welcome_to_join] = grouped[:welcome_to_join] to_notify[:unreachable] = grouped[:unreachable] already_covered_ids.concat(to_notify[:direct_mentions]) end def group_name_mentions @group_mentions_from_cooked ||= normalized_mentions( Nokogiri::HTML5.fragment(@chat_message.cooked).css(".mention-group").map(&:text), ) end def visible_groups @visible_groups ||= Group.where("LOWER(name) IN (?)", group_name_mentions).visible_groups(@user) end def expand_group_mentions(to_notify, already_covered_ids, skip) return [] if skip || visible_groups.empty? mentionable_groups = Group.mentionable(@user, include_public: false).where(id: visible_groups.map(&:id)) mentions_disabled = visible_groups - mentionable_groups too_many_members, mentionable = mentionable_groups.partition do |group| group.user_count > SiteSetting.max_users_notified_per_group_mention end to_notify[:group_mentions_disabled] = mentions_disabled to_notify[:too_many_members] = too_many_members mentionable.each { |g| to_notify[g.name.downcase] = [] } reached_by_group = chat_users .includes(:groups) .joins(:groups) .where(groups: mentionable) .where.not(id: already_covered_ids) grouped = group_users_to_notify(reached_by_group) grouped[:already_participating].each do |user| # When a user is a member of multiple mentioned groups, # the most far to the left should take precedence. ordered_group_names = group_name_mentions & mentionable.map { |mg| mg.name.downcase } user_group_names = user.groups.map { |ug| ug.name.downcase } group_name = ordered_group_names.detect { |gn| user_group_names.include?(gn) } to_notify[group_name] << user.id already_covered_ids << user.id end to_notify[:welcome_to_join] = to_notify[:welcome_to_join].concat(grouped[:welcome_to_join]) to_notify[:unreachable] = to_notify[:unreachable].concat(grouped[:unreachable]) end def notify_creator_of_inaccessible_mentions(to_notify) inaccessible = to_notify.extract!( :unreachable, :welcome_to_join, :too_many_members, :group_mentions_disabled, ) return if inaccessible.values.all?(&:blank?) ChatPublisher.publish_inaccessible_mentions( @user.id, @chat_message, inaccessible[:unreachable].to_a, inaccessible[:welcome_to_join].to_a, inaccessible[:too_many_members].to_a, inaccessible[:group_mentions_disabled].to_a, ) end # Filters out users from global, here, group, and direct mentions that are # ignoring or muting the creator of the message, so they will not receive # a notification via the ChatNotifyMentioned job and are not prompted for # invitation by the creator. def filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids) screen_targets = already_covered_ids.concat(to_notify[:welcome_to_join].map(&:id)) return if screen_targets.blank? screener = UserCommScreener.new(acting_user: @user, target_user_ids: screen_targets) to_notify .except(:unreachable, :welcome_to_join) .each do |key, user_ids| to_notify[key] = user_ids.reject { |user_id| screener.ignoring_or_muting_actor?(user_id) } end # :welcome_to_join contains users because it's serialized by MB. to_notify[:welcome_to_join] = to_notify[:welcome_to_join].reject do |user| screener.ignoring_or_muting_actor?(user.id) end already_covered_ids.reject! do |already_covered| screener.ignoring_or_muting_actor?(already_covered) end end def notify_mentioned_users(to_notify, already_notified_user_ids: []) Jobs.enqueue( :chat_notify_mentioned, { chat_message_id: @chat_message.id, to_notify_ids_map: to_notify.as_json, already_notified_user_ids: already_notified_user_ids, timestamp: @timestamp, }, ) end def notify_watching_users(except: []) Jobs.enqueue( :chat_notify_watching, { chat_message_id: @chat_message.id, except_user_ids: except, timestamp: @timestamp }, ) end end