Webpush gem throws errors such as `WebPush::ExpiredSubscription`, `WebPush::InvalidSubscription`, `WebPush::Unauthorized`. We handled only ExpiredSubscription. If the SDK threw any other errors, it would pause sending the notification to all other devices for that user. This change would update the logic to remove the expired subscription and handle the rest of the errors gracefully. Fixes https://linear.app/chatwoot/issue/CW-3399/webpushinvalidsubscription-host-fcmgoogleapiscom-nethttpnotfound-404
164 lines
4.4 KiB
Ruby
164 lines
4.4 KiB
Ruby
class Notification::PushNotificationService
|
|
include Rails.application.routes.url_helpers
|
|
|
|
pattr_initialize [:notification!]
|
|
|
|
def perform
|
|
return unless user_subscribed_to_notification?
|
|
|
|
notification_subscriptions.each do |subscription|
|
|
send_browser_push(subscription)
|
|
send_fcm_push(subscription)
|
|
send_push_via_chatwoot_hub(subscription)
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
delegate :user, to: :notification
|
|
delegate :notification_subscriptions, to: :user
|
|
delegate :notification_settings, to: :user
|
|
|
|
def user_subscribed_to_notification?
|
|
notification_setting = notification_settings.find_by(account_id: notification.account.id)
|
|
return true if notification_setting.public_send("push_#{notification.notification_type}?")
|
|
|
|
false
|
|
end
|
|
|
|
def conversation
|
|
@conversation ||= notification.conversation
|
|
end
|
|
|
|
def push_message
|
|
{
|
|
title: notification.push_message_title,
|
|
tag: "#{notification.notification_type}_#{conversation.display_id}_#{notification.id}",
|
|
url: push_url
|
|
}
|
|
end
|
|
|
|
def push_url
|
|
app_account_conversation_url(account_id: conversation.account_id, id: conversation.display_id)
|
|
end
|
|
|
|
def can_send_browser_push?(subscription)
|
|
VapidService.public_key && subscription.browser_push?
|
|
end
|
|
|
|
def browser_push_payload(subscription)
|
|
{
|
|
message: JSON.generate(push_message),
|
|
endpoint: subscription.subscription_attributes['endpoint'],
|
|
p256dh: subscription.subscription_attributes['p256dh'],
|
|
auth: subscription.subscription_attributes['auth'],
|
|
vapid: {
|
|
subject: push_url,
|
|
public_key: VapidService.public_key,
|
|
private_key: VapidService.private_key
|
|
},
|
|
ssl_timeout: 5,
|
|
open_timeout: 5,
|
|
read_timeout: 5
|
|
}
|
|
end
|
|
|
|
def send_browser_push(subscription)
|
|
return unless can_send_browser_push?(subscription)
|
|
|
|
WebPush.payload_send(browser_push_payload(subscription))
|
|
Rails.logger.info("Browser push sent to #{user.email} with title #{push_message[:title]}")
|
|
rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized => e
|
|
Rails.logger.info "WebPush subscription expired: #{e.message}"
|
|
subscription.destroy!
|
|
rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e
|
|
Rails.logger.error "WebPush operation error: #{e.message}"
|
|
rescue StandardError => e
|
|
ChatwootExceptionTracker.new(e, account: notification.account).capture_exception
|
|
true
|
|
end
|
|
|
|
def send_fcm_push(subscription)
|
|
return unless firebase_credentials_present?
|
|
return unless subscription.fcm?
|
|
|
|
fcm_service = Notification::FcmService.new(
|
|
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil), GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
|
|
)
|
|
fcm = fcm_service.fcm_client
|
|
response = fcm.send_v1(fcm_options(subscription))
|
|
remove_subscription_if_error(subscription, response)
|
|
end
|
|
|
|
def send_push_via_chatwoot_hub(subscription)
|
|
return if firebase_credentials_present?
|
|
return unless chatwoot_hub_enabled?
|
|
return unless subscription.fcm?
|
|
|
|
ChatwootHub.send_push(fcm_options(subscription))
|
|
end
|
|
|
|
def firebase_credentials_present?
|
|
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil) && GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
|
|
end
|
|
|
|
def chatwoot_hub_enabled?
|
|
ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true))
|
|
end
|
|
|
|
def remove_subscription_if_error(subscription, response)
|
|
if JSON.parse(response[:body])['results']&.first&.keys&.include?('error')
|
|
subscription.destroy!
|
|
else
|
|
Rails.logger.info("FCM push sent to #{user.email} with title #{push_message[:title]}")
|
|
end
|
|
end
|
|
|
|
def fcm_options(subscription)
|
|
{
|
|
'token': subscription.subscription_attributes['push_token'],
|
|
'data': fcm_data,
|
|
'notification': fcm_notification,
|
|
'android': fcm_android_options,
|
|
'apns': fcm_apns_options,
|
|
'fcm_options': {
|
|
analytics_label: 'Label'
|
|
}
|
|
}
|
|
end
|
|
|
|
def fcm_data
|
|
{
|
|
payload: {
|
|
data: {
|
|
notification: notification.fcm_push_data
|
|
}
|
|
}.to_json
|
|
}
|
|
end
|
|
|
|
def fcm_notification
|
|
{
|
|
title: notification.push_message_title,
|
|
body: notification.push_message_body
|
|
}
|
|
end
|
|
|
|
def fcm_android_options
|
|
{
|
|
priority: 'high'
|
|
}
|
|
end
|
|
|
|
def fcm_apns_options
|
|
{
|
|
payload: {
|
|
aps: {
|
|
sound: 'default',
|
|
category: Time.zone.now.to_i.to_s
|
|
}
|
|
}
|
|
}
|
|
end
|
|
end
|