mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 08:57:10 -06:00
605 lines
19 KiB
Ruby
605 lines
19 KiB
Ruby
require_dependency 'distributed_mutex'
|
|
require_dependency 'user_action_creator'
|
|
|
|
class PostAlerter
|
|
def self.post_created(post, opts = {})
|
|
PostAlerter.new(opts).after_save_post(post, true)
|
|
post
|
|
end
|
|
|
|
def initialize(default_opts = {})
|
|
@default_opts = default_opts
|
|
end
|
|
|
|
def not_allowed?(user, post)
|
|
user.blank? ||
|
|
user.id < 0 ||
|
|
user.id == post.user_id
|
|
end
|
|
|
|
def all_allowed_users(post)
|
|
@all_allowed_users ||= post.topic.all_allowed_users.reject { |u| not_allowed?(u, post) }
|
|
end
|
|
|
|
def allowed_users(post)
|
|
@allowed_users ||= post.topic.allowed_users.reject { |u| not_allowed?(u, post) }
|
|
end
|
|
|
|
def allowed_group_users(post)
|
|
@allowed_group_users ||= post.topic.allowed_group_users.reject { |u| not_allowed?(u, post) }
|
|
end
|
|
|
|
def directly_targeted_users(post)
|
|
allowed_users(post) - allowed_group_users(post)
|
|
end
|
|
|
|
def indirectly_targeted_users(post)
|
|
allowed_group_users(post)
|
|
end
|
|
|
|
def only_allowed_users(users, post)
|
|
return users unless post.topic.private_message?
|
|
users.select { |u| all_allowed_users(post).include?(u) }
|
|
end
|
|
|
|
def notify_about_reply?(post)
|
|
# small actions can be whispers in this case they will have an action code
|
|
# we never want to notify on this
|
|
post.post_type == Post.types[:regular] ||
|
|
(post.post_type == Post.types[:whisper] && post.action_code.nil?)
|
|
end
|
|
|
|
def after_save_post(post, new_record = false)
|
|
notified = [post.user, post.last_editor].uniq
|
|
|
|
# mentions (users/groups)
|
|
mentioned_groups, mentioned_users = extract_mentions(post)
|
|
|
|
if mentioned_groups || mentioned_users
|
|
mentioned_opts = {}
|
|
editor = post.last_editor
|
|
|
|
if post.last_editor_id != post.user_id
|
|
# Mention comes from an edit by someone else, so notification should say who added the mention.
|
|
mentioned_opts = { user_id: editor.id, original_username: editor.username, display_username: editor.username }
|
|
end
|
|
|
|
expand_group_mentions(mentioned_groups, post) do |group, users|
|
|
users = only_allowed_users(users, post)
|
|
notified += notify_users(users - notified, :group_mentioned, post, mentioned_opts.merge(group: group))
|
|
end
|
|
|
|
if mentioned_users
|
|
mentioned_users = only_allowed_users(mentioned_users, post)
|
|
notified += notify_users(mentioned_users - notified, :mentioned, post, mentioned_opts)
|
|
end
|
|
end
|
|
|
|
# replies
|
|
reply_to_user = post.reply_notification_target
|
|
|
|
if new_record && reply_to_user && !notified.include?(reply_to_user) && notify_about_reply?(post)
|
|
notified += notify_non_pm_users(reply_to_user, :replied, post)
|
|
end
|
|
|
|
# quotes
|
|
quoted_users = extract_quoted_users(post)
|
|
notified += notify_non_pm_users(quoted_users - notified, :quoted, post)
|
|
|
|
# linked
|
|
linked_users = extract_linked_users(post)
|
|
notified += notify_non_pm_users(linked_users - notified, :linked, post)
|
|
|
|
# private messages
|
|
if new_record
|
|
if post.topic.private_message?
|
|
notify_pm_users(post, reply_to_user, notified)
|
|
elsif notify_about_reply?(post)
|
|
notify_post_users(post, notified)
|
|
end
|
|
end
|
|
|
|
sync_group_mentions(post, mentioned_groups)
|
|
|
|
if new_record && post.post_number == 1
|
|
topic = post.topic
|
|
|
|
if topic.present?
|
|
watchers = category_watchers(topic) + tag_watchers(topic) + group_watchers(topic)
|
|
notify_first_post_watchers(post, watchers)
|
|
end
|
|
end
|
|
end
|
|
|
|
def group_watchers(topic)
|
|
GroupUser.where(
|
|
group_id: topic.allowed_groups.pluck(:group_id),
|
|
notification_level: GroupUser.notification_levels[:watching_first_post]
|
|
).pluck(:user_id)
|
|
end
|
|
|
|
def tag_watchers(topic)
|
|
topic.tag_users
|
|
.where(notification_level: TagUser.notification_levels[:watching_first_post])
|
|
.pluck(:user_id)
|
|
end
|
|
|
|
def category_watchers(topic)
|
|
topic.category_users
|
|
.where(notification_level: CategoryUser.notification_levels[:watching_first_post])
|
|
.pluck(:user_id)
|
|
end
|
|
|
|
def notify_first_post_watchers(post, user_ids)
|
|
return if user_ids.blank?
|
|
user_ids.uniq!
|
|
|
|
warn_if_not_sidekiq
|
|
|
|
# Don't notify the OP
|
|
user_ids -= [post.user_id]
|
|
users = User.where(id: user_ids)
|
|
|
|
DiscourseEvent.trigger(:before_create_notifications_for_users, users, post)
|
|
users.each do |user|
|
|
create_notification(user, Notification.types[:watching_first_post], post)
|
|
end
|
|
end
|
|
|
|
def sync_group_mentions(post, mentioned_groups)
|
|
GroupMention.where(post_id: post.id).destroy_all
|
|
return if mentioned_groups.blank?
|
|
|
|
mentioned_groups.each do |group|
|
|
GroupMention.create(post_id: post.id, group_id: group.id)
|
|
end
|
|
end
|
|
|
|
def unread_posts(user, topic)
|
|
Post.secured(Guardian.new(user))
|
|
.where('post_number > COALESCE((
|
|
SELECT last_read_post_number FROM topic_users tu
|
|
WHERE tu.user_id = ? AND tu.topic_id = ? ),0)',
|
|
user.id, topic.id)
|
|
.where('reply_to_user_id = ? OR exists(
|
|
SELECT 1 from topic_users tu
|
|
WHERE tu.user_id = ? AND
|
|
tu.topic_id = ? AND
|
|
notification_level = ?
|
|
)', user.id, user.id, topic.id, TopicUser.notification_levels[:watching])
|
|
.where(topic_id: topic.id)
|
|
end
|
|
|
|
def first_unread_post(user, topic)
|
|
unread_posts(user, topic).order('post_number').first
|
|
end
|
|
|
|
def unread_count(user, topic)
|
|
unread_posts(user, topic).count
|
|
end
|
|
|
|
def destroy_notifications(user, types, topic)
|
|
return if user.blank?
|
|
return unless Guardian.new(user).can_see?(topic)
|
|
|
|
User.transaction do
|
|
user.notifications.where(
|
|
notification_type: types,
|
|
topic_id: topic.id
|
|
).destroy_all
|
|
|
|
# Reload so notification counts sync up correctly
|
|
user.reload
|
|
end
|
|
end
|
|
|
|
NOTIFIABLE_TYPES = [:mentioned, :replied, :quoted, :posted, :linked, :private_message, :group_mentioned, :watching_first_post].map { |t|
|
|
Notification.types[t]
|
|
}
|
|
|
|
def group_stats(topic)
|
|
sql = <<~SQL
|
|
SELECT COUNT(*) FROM topics t
|
|
JOIN topic_allowed_groups g ON g.group_id = :group_id AND g.topic_id = t.id
|
|
LEFT JOIN group_archived_messages a ON a.topic_id = t.id AND a.group_id = g.group_id
|
|
WHERE a.id IS NULL AND t.deleted_at is NULL AND t.archetype = 'private_message'
|
|
SQL
|
|
|
|
topic.allowed_groups.map do |g|
|
|
{
|
|
group_id: g.id,
|
|
group_name: g.name.downcase,
|
|
inbox_count: DB.query_single(sql, group_id: g.id).first.to_i
|
|
}
|
|
end
|
|
end
|
|
|
|
def notify_group_summary(user, post)
|
|
@group_stats ||= {}
|
|
stats = (@group_stats[post.topic_id] ||= group_stats(post.topic))
|
|
return unless stats
|
|
|
|
group_id = post.topic
|
|
.topic_allowed_groups
|
|
.where(group_id: user.groups.pluck(:id))
|
|
.pluck(:group_id).first
|
|
|
|
stat = stats.find { |s| s[:group_id] == group_id }
|
|
return unless stat && stat[:inbox_count] > 0
|
|
|
|
notification_type = Notification.types[:group_message_summary]
|
|
|
|
DistributedMutex.synchronize("group_message_notify_#{user.id}") do
|
|
Notification.where(notification_type: notification_type, user_id: user.id).each do |n|
|
|
n.destroy if n.data_hash[:group_id] == stat[:group_id]
|
|
end
|
|
|
|
Notification.create(
|
|
notification_type: notification_type,
|
|
user_id: user.id,
|
|
data: {
|
|
group_id: stat[:group_id],
|
|
group_name: stat[:group_name],
|
|
inbox_count: stat[:inbox_count],
|
|
username: user.username_lower
|
|
}.to_json
|
|
)
|
|
end
|
|
|
|
# TODO decide if it makes sense to also publish a desktop notification
|
|
end
|
|
|
|
def should_notify_edit?(notification, opts)
|
|
return notification.data_hash["display_username"] != opts[:display_username]
|
|
end
|
|
|
|
def should_notify_like?(user, notification)
|
|
return true if user.user_option.like_notification_frequency == UserOption.like_notification_frequency_type[:always]
|
|
return true if user.user_option.like_notification_frequency == UserOption.like_notification_frequency_type[:first_time_and_daily] && notification.created_at < 1.day.ago
|
|
false
|
|
end
|
|
|
|
def should_notify_previous?(user, notification, opts)
|
|
case notification.notification_type
|
|
when Notification.types[:edited] then should_notify_edit?(notification, opts)
|
|
when Notification.types[:liked] then should_notify_like?(user, notification)
|
|
else false
|
|
end
|
|
end
|
|
|
|
COLLAPSED_NOTIFICATION_TYPES ||= [
|
|
Notification.types[:replied],
|
|
Notification.types[:quoted],
|
|
Notification.types[:posted],
|
|
Notification.types[:private_message],
|
|
]
|
|
|
|
def create_notification(user, type, post, opts = {})
|
|
opts = @default_opts.merge(opts)
|
|
|
|
DiscourseEvent.trigger(:before_create_notification, user, type, post, opts)
|
|
|
|
return if user.blank?
|
|
return if user.id < 0
|
|
|
|
return if type == Notification.types[:liked] && user.user_option.like_notification_frequency == UserOption.like_notification_frequency_type[:never]
|
|
|
|
# Make sure the user can see the post
|
|
return unless Guardian.new(user).can_see?(post)
|
|
|
|
return if user.staged? && post.topic.category&.mailinglist_mirror?
|
|
|
|
notifier_id = opts[:user_id] || post.user_id # xxxxx look at revision history
|
|
|
|
# apply muting here
|
|
return if notifier_id && MutedUser.where(user_id: user.id, muted_user_id: notifier_id)
|
|
.joins(:muted_user)
|
|
.where('NOT admin AND NOT moderator')
|
|
.exists?
|
|
|
|
# skip if muted on the topic
|
|
return if TopicUser.where(
|
|
topic: post.topic,
|
|
user: user,
|
|
notification_level: TopicUser.notification_levels[:muted]
|
|
).exists?
|
|
|
|
# skip if muted on the group
|
|
if group = opts[:group]
|
|
return if GroupUser.where(
|
|
group_id: opts[:group_id],
|
|
user_id: user.id,
|
|
notification_level: TopicUser.notification_levels[:muted]
|
|
).exists?
|
|
end
|
|
|
|
# Don't notify the same user about the same notification on the same post
|
|
existing_notification = user.notifications
|
|
.order("notifications.id DESC")
|
|
.find_by(topic_id: post.topic_id,
|
|
post_number: post.post_number,
|
|
notification_type: type)
|
|
|
|
return if existing_notification && !should_notify_previous?(user, existing_notification, opts)
|
|
|
|
notification_data = {}
|
|
|
|
if existing_notification &&
|
|
existing_notification.created_at > 1.day.ago &&
|
|
user.user_option.like_notification_frequency == UserOption.like_notification_frequency_type[:always]
|
|
|
|
data = existing_notification.data_hash
|
|
notification_data["username2"] = data["display_username"]
|
|
notification_data["count"] = (data["count"] || 1).to_i + 1
|
|
# don't use destroy so we don't trigger a notification count refresh
|
|
Notification.where(id: existing_notification.id).destroy_all
|
|
end
|
|
|
|
collapsed = false
|
|
|
|
if COLLAPSED_NOTIFICATION_TYPES.include?(type)
|
|
destroy_notifications(user, COLLAPSED_NOTIFICATION_TYPES, post.topic)
|
|
collapsed = true
|
|
end
|
|
|
|
original_post = post
|
|
original_username = opts[:display_username].presence || post.username
|
|
|
|
if collapsed
|
|
post = first_unread_post(user, post.topic) || post
|
|
count = unread_count(user, post.topic)
|
|
if count > 1
|
|
I18n.with_locale(user.effective_locale) do
|
|
opts[:display_username] = I18n.t('embed.replies', count: count)
|
|
end
|
|
end
|
|
end
|
|
|
|
UserActionCreator.log_notification(original_post, user, type, opts[:acting_user_id])
|
|
|
|
topic_title = post.topic.title
|
|
# when sending a private message email, keep the original title
|
|
if post.topic.private_message? && modifications = post.revisions.map(&:modifications)
|
|
if first_title_modification = modifications.find { |m| m.has_key?("title") }
|
|
topic_title = first_title_modification["title"][0]
|
|
end
|
|
end
|
|
|
|
notification_data.merge!(topic_title: topic_title,
|
|
original_post_id: original_post.id,
|
|
original_post_type: original_post.post_type,
|
|
original_username: original_username,
|
|
revision_number: opts[:revision_number],
|
|
display_username: opts[:display_username] || post.user.username)
|
|
|
|
if group = opts[:group]
|
|
notification_data[:group_id] = group.id
|
|
notification_data[:group_name] = group.name
|
|
end
|
|
|
|
if original_post.via_email && (incoming_email = original_post.incoming_email)
|
|
skip_send_email = contains_email_address?(incoming_email.to_addresses, user) ||
|
|
contains_email_address?(incoming_email.cc_addresses, user)
|
|
else
|
|
skip_send_email = opts[:skip_send_email]
|
|
end
|
|
|
|
# Create the notification
|
|
created = user.notifications.create!(
|
|
notification_type: type,
|
|
topic_id: post.topic_id,
|
|
post_number: post.post_number,
|
|
post_action_id: opts[:post_action_id],
|
|
data: notification_data.to_json,
|
|
skip_send_email: skip_send_email
|
|
)
|
|
|
|
if created.id && !existing_notification && NOTIFIABLE_TYPES.include?(type) && !user.suspended?
|
|
create_notification_alert(user: user, post: original_post, notification_type: type, username: original_username)
|
|
end
|
|
|
|
created.id ? created : nil
|
|
end
|
|
|
|
def create_notification_alert(user:, post:, notification_type:, excerpt: nil, username: nil)
|
|
if post_url = post.url
|
|
payload = {
|
|
notification_type: notification_type,
|
|
post_number: post.post_number,
|
|
topic_title: post.topic.title,
|
|
topic_id: post.topic.id,
|
|
excerpt: excerpt || post.excerpt(400, text_entities: true, strip_links: true, remap_emoji: true),
|
|
username: username || post.username,
|
|
post_url: post_url
|
|
}
|
|
|
|
MessageBus.publish("/notification-alert/#{user.id}", payload, user_ids: [user.id])
|
|
push_notification(user, payload)
|
|
DiscourseEvent.trigger(:post_notification_alert, user, payload)
|
|
end
|
|
end
|
|
|
|
def contains_email_address?(addresses, user)
|
|
return false if addresses.blank?
|
|
addresses.split(";").include?(user.email)
|
|
end
|
|
|
|
def push_notification(user, payload)
|
|
if user.push_subscriptions.exists?
|
|
Jobs.enqueue(:send_push_notification, user_id: user.id, payload: payload)
|
|
end
|
|
|
|
if SiteSetting.allow_user_api_key_scopes.split("|").include?("push") && SiteSetting.allowed_user_api_push_urls.present?
|
|
clients = user.user_api_keys
|
|
.where("('push' = ANY(scopes) OR 'notifications' = ANY(scopes))")
|
|
.where("push_url IS NOT NULL")
|
|
.where("position(push_url IN ?) > 0", SiteSetting.allowed_user_api_push_urls)
|
|
.where("revoked_at IS NULL")
|
|
.pluck(:client_id, :push_url)
|
|
|
|
if clients.length > 0
|
|
Jobs.enqueue(:push_notification, clients: clients, payload: payload, user_id: user.id)
|
|
end
|
|
end
|
|
end
|
|
|
|
def expand_group_mentions(groups, post)
|
|
return unless post.user && groups
|
|
|
|
Group.mentionable(post.user).where(id: groups.map(&:id)).each do |group|
|
|
next if group.user_count >= SiteSetting.max_users_notified_per_group_mention
|
|
yield group, group.users
|
|
end
|
|
|
|
end
|
|
|
|
# TODO: Move to post-analyzer?
|
|
def extract_mentions(post)
|
|
mentions = post.raw_mentions
|
|
return if mentions.blank?
|
|
|
|
groups = Group.where('LOWER(name) IN (?)', mentions)
|
|
mentions -= groups.map(&:name).map(&:downcase)
|
|
groups = nil if groups.empty?
|
|
|
|
if mentions.present?
|
|
users = User.where(username_lower: mentions).where.not(id: post.user_id)
|
|
users = nil if users.empty?
|
|
end
|
|
|
|
[groups, users]
|
|
end
|
|
|
|
# TODO: Move to post-analyzer?
|
|
# Returns a list of users who were quoted in the post
|
|
def extract_quoted_users(post)
|
|
post.raw.scan(/\[quote=\"([^,]+),.+\"\]/).uniq.map do |m|
|
|
User.find_by("username_lower = :username AND id != :id", username: m.first.strip.downcase, id: post.user_id)
|
|
end.compact
|
|
end
|
|
|
|
def extract_linked_users(post)
|
|
post.topic_links.where(reflection: false).map do |link|
|
|
linked_post = link.link_post
|
|
if !linked_post && topic = link.link_topic
|
|
linked_post = topic.posts.find_by(post_number: 1)
|
|
end
|
|
(linked_post && post.user_id != linked_post.user_id && linked_post.user) || nil
|
|
end.compact
|
|
end
|
|
|
|
# Notify a bunch of users
|
|
def notify_non_pm_users(users, type, post, opts = {})
|
|
return [] if post.topic&.private_message?
|
|
|
|
notify_users(users, type, post, opts)
|
|
end
|
|
|
|
def notify_users(users, type, post, opts = {})
|
|
users = [users] unless users.is_a?(Array)
|
|
users = users.reject { |u| u.staged? } if post.topic&.private_message?
|
|
|
|
warn_if_not_sidekiq
|
|
|
|
DiscourseEvent.trigger(:before_create_notifications_for_users, users, post)
|
|
users.each do |u|
|
|
create_notification(u, Notification.types[type], post, opts)
|
|
end
|
|
|
|
users
|
|
end
|
|
|
|
def notify_pm_users(post, reply_to_user, notified)
|
|
return unless post.topic
|
|
|
|
warn_if_not_sidekiq
|
|
|
|
# users that aren't part of any mentioned groups
|
|
users = directly_targeted_users(post).reject { |u| notified.include?(u) }
|
|
DiscourseEvent.trigger(:before_create_notifications_for_users, users, post)
|
|
users.each do |user|
|
|
notification_level = TopicUser.get(post.topic, user)&.notification_level
|
|
if reply_to_user == user || notification_level == TopicUser.notification_levels[:watching] || user.staged?
|
|
create_notification(user, Notification.types[:private_message], post)
|
|
end
|
|
end
|
|
|
|
# users that are part of all mentionned groups
|
|
users = indirectly_targeted_users(post).reject { |u| notified.include?(u) }
|
|
DiscourseEvent.trigger(:before_create_notifications_for_users, users, post)
|
|
users.each do |user|
|
|
case TopicUser.get(post.topic, user)&.notification_level
|
|
when TopicUser.notification_levels[:watching]
|
|
# only create a notification when watching the group
|
|
create_notification(user, Notification.types[:private_message], post)
|
|
when TopicUser.notification_levels[:tracking]
|
|
notify_group_summary(user, post)
|
|
end
|
|
end
|
|
end
|
|
|
|
def notify_post_users(post, notified)
|
|
return unless post.topic
|
|
|
|
warn_if_not_sidekiq
|
|
|
|
condition = <<~SQL
|
|
id IN (
|
|
SELECT user_id
|
|
FROM topic_users
|
|
WHERE notification_level = :watching
|
|
AND topic_id = :topic_id
|
|
|
|
UNION
|
|
|
|
SELECT cu.user_id
|
|
FROM category_users cu
|
|
LEFT JOIN topic_users tu ON tu.user_id = cu.user_id
|
|
AND tu.topic_id = :topic_id
|
|
WHERE cu.notification_level = :watching
|
|
AND cu.category_id = :category_id
|
|
AND tu.user_id IS NULL
|
|
|
|
/*tags*/
|
|
)
|
|
SQL
|
|
|
|
tag_ids = post.topic.topic_tags.pluck('topic_tags.tag_id')
|
|
|
|
if tag_ids.present?
|
|
condition.sub! "/*tags*/", <<~SQL
|
|
UNION
|
|
|
|
SELECT tag_users.user_id
|
|
FROM tag_users
|
|
LEFT JOIN topic_users tu ON tu.user_id = tag_users.user_id
|
|
AND tu.topic_id = :topic_id
|
|
WHERE tag_users.notification_level = :watching
|
|
AND tag_users.tag_id IN (:tag_ids)
|
|
AND tu.user_id IS NULL
|
|
SQL
|
|
end
|
|
|
|
notify = User.where(condition,
|
|
watching: TopicUser.notification_levels[:watching],
|
|
topic_id: post.topic_id,
|
|
category_id: post.topic.category_id,
|
|
tag_ids: tag_ids
|
|
)
|
|
|
|
exclude_user_ids = notified.map(&:id)
|
|
notify = notify.where("id NOT IN (?)", exclude_user_ids) if exclude_user_ids.present?
|
|
|
|
DiscourseEvent.trigger(:before_create_notifications_for_users, notify, post)
|
|
notify.pluck(:id).each do |user_id|
|
|
user = User.find_by(id: user_id)
|
|
create_notification(user, Notification.types[:posted], post)
|
|
end
|
|
end
|
|
|
|
def warn_if_not_sidekiq
|
|
Rails.logger.warn("PostAlerter.#{caller_locations(1, 1)[0].label} was called outside of sidekiq") unless Sidekiq.server?
|
|
end
|
|
|
|
end
|