discourse/app/services/push_notification_pusher.rb
David Battersby 6ec8728ebf
DEV: refactor live notifications setting in user preferences (#28145)
This change is mainly a refactor of the desktop notifications service to improve readability and have standardised values for tracking state for current user in regards to the Notification API and Push API.

Also improves readability when handling push notification jobs, especially in scenarios where the push_notification_time_window_mins site setting is set to 0, which will allow sending push notifications instantly.
2024-08-02 17:25:15 +04:00

188 lines
5.8 KiB
Ruby

# frozen_string_literal: true
class PushNotificationPusher
TOKEN_VALID_FOR_SECONDS ||= 5 * 60
CONNECTION_TIMEOUT_SECONDS = 5
def self.push(user, payload)
message = nil
I18n.with_locale(user.effective_locale) do
notification_icon_name = Notification.types[payload[:notification_type]]
if !File.exist?(
File.expand_path(
"../../app/assets/images/push-notifications/#{notification_icon_name}.png",
__dir__,
),
)
notification_icon_name = "discourse"
end
notification_icon =
ActionController::Base.helpers.image_url("push-notifications/#{notification_icon_name}.png")
message = {
title: payload[:translated_title] || title(payload),
body: payload[:excerpt],
badge: get_badge,
icon: notification_icon,
tag: payload[:tag] || "#{Discourse.current_hostname}-#{payload[:topic_id]}",
base_url: Discourse.base_url,
url: payload[:post_url],
}
subscriptions(user).each { |subscription| send_notification(user, subscription, message) }
end
message
end
def self.title(payload)
translation_key =
case payload[:notification_type]
when Notification.types[:watching_category_or_tag]
# For watching_category_or_tag, the notification could be for either a new post or new topic.
# Instead of duplicating translations, we can rely on 'watching_first_post' for new topics,
# and 'posted' for new posts.
type = payload[:post_number] == 1 ? "watching_first_post" : "posted"
"discourse_push_notifications.popup.#{type}"
else
"discourse_push_notifications.popup.#{Notification.types[payload[:notification_type]]}"
end
# Payload modifier used to adjust arguments to the translation
payload =
DiscoursePluginRegistry.apply_modifier(:push_notification_pusher_title_payload, payload)
I18n.t(
translation_key,
site_title: SiteSetting.title,
topic: payload[:topic_title],
username: payload[:username],
group_name: payload[:group_name],
)
end
def self.subscriptions(user)
user.push_subscriptions
end
def self.clear_subscriptions(user)
user.push_subscriptions.clear
end
def self.subscribe(user, push_params, send_confirmation)
data = push_params.to_json
subscriptions = PushSubscription.where(user: user, data: data)
subscriptions_count = subscriptions.count
new_subscription =
if subscriptions_count > 1
subscriptions.destroy_all
PushSubscription.create!(user: user, data: data)
elsif subscriptions_count == 0
PushSubscription.create!(user: user, data: data)
end
if send_confirmation == "true"
message = {
title:
I18n.t("discourse_push_notifications.popup.confirm_title", site_title: SiteSetting.title),
body: I18n.t("discourse_push_notifications.popup.confirm_body"),
icon: ActionController::Base.helpers.image_url("push-notifications/check.png"),
badge: get_badge,
tag: "#{Discourse.current_hostname}-subscription",
}
send_notification(user, new_subscription, message)
end
end
def self.unsubscribe(user, subscription)
PushSubscription.find_by(user: user, data: subscription.to_json)&.destroy!
end
def self.get_badge
if (url = SiteSetting.site_push_notifications_icon_url).present?
url
else
ActionController::Base.helpers.image_url("push-notifications/discourse.png")
end
end
MAX_ERRORS ||= 3
MIN_ERROR_DURATION ||= 86_400 # 1 day
def self.handle_generic_error(subscription, error, user, endpoint, message)
subscription.error_count += 1
subscription.first_error_at ||= Time.zone.now
delta = Time.zone.now - subscription.first_error_at
if subscription.error_count >= MAX_ERRORS && delta > MIN_ERROR_DURATION
subscription.destroy!
else
subscription.save!
end
Discourse.warn_exception(
error,
message: "Failed to send push notification",
env: {
user_id: user.id,
endpoint: endpoint,
message: message.to_json,
},
)
end
def self.send_notification(user, subscription, message)
parsed_data = subscription.parsed_data
endpoint = parsed_data["endpoint"]
p256dh = parsed_data.dig("keys", "p256dh")
auth = parsed_data.dig("keys", "auth")
if (endpoint.blank? || p256dh.blank? || auth.blank?)
subscription.destroy!
return
end
begin
WebPush.payload_send(
endpoint: endpoint,
message: message.to_json,
p256dh: p256dh,
auth: auth,
vapid: {
subject: Discourse.base_url,
public_key: SiteSetting.vapid_public_key,
private_key: SiteSetting.vapid_private_key,
expiration: TOKEN_VALID_FOR_SECONDS,
},
open_timeout: CONNECTION_TIMEOUT_SECONDS,
read_timeout: CONNECTION_TIMEOUT_SECONDS,
ssl_timeout: CONNECTION_TIMEOUT_SECONDS,
)
if subscription.first_error_at || subscription.error_count != 0
subscription.update_columns(error_count: 0, first_error_at: nil)
end
DiscourseEvent.trigger(:push_notification_sent, user, message)
rescue WebPush::ExpiredSubscription
subscription.destroy!
rescue WebPush::ResponseError => e
if e.response.message == "MismatchSenderId"
subscription.destroy!
else
handle_generic_error(subscription, e, user, endpoint, message)
end
rescue Timeout::Error => e
handle_generic_error(subscription, e, user, endpoint, message)
rescue OpenSSL::SSL::SSLError => e
handle_generic_error(subscription, e, user, endpoint, message)
end
end
private_class_method :send_notification
private_class_method :handle_generic_error
end