discourse/app/services/push_notification_pusher.rb

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