feat: FCM HTTP v1 API changes (#9629)
Fixes https://linear.app/chatwoot/issue/CW-3210/legacy-firebase-changes
This commit is contained in:
@@ -40,7 +40,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
when 'email'
|
||||
['MAILER_INBOUND_EMAIL_DOMAIN']
|
||||
else
|
||||
%w[ENABLE_ACCOUNT_SIGNUP]
|
||||
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
40
app/services/notification/fcm_service.rb
Normal file
40
app/services/notification/fcm_service.rb
Normal file
@@ -0,0 +1,40 @@
|
||||
class Notification::FcmService
|
||||
SCOPES = ['https://www.googleapis.com/auth/firebase.messaging'].freeze
|
||||
|
||||
def initialize(project_id, credentials)
|
||||
@project_id = project_id
|
||||
@credentials = credentials
|
||||
@token_info = nil
|
||||
end
|
||||
|
||||
def fcm_client
|
||||
FCM.new(current_token, credentials_path, @project_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def current_token
|
||||
@token_info = generate_token if @token_info.nil? || token_expired?
|
||||
@token_info[:token]
|
||||
end
|
||||
|
||||
def token_expired?
|
||||
Time.zone.now >= @token_info[:expires_at]
|
||||
end
|
||||
|
||||
def generate_token
|
||||
authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
|
||||
json_key_io: credentials_path,
|
||||
scope: SCOPES
|
||||
)
|
||||
token = authorizer.fetch_access_token!
|
||||
{
|
||||
token: token['access_token'],
|
||||
expires_at: Time.zone.now + token['expires_in'].to_i
|
||||
}
|
||||
end
|
||||
|
||||
def credentials_path
|
||||
StringIO.new(@credentials)
|
||||
end
|
||||
end
|
||||
@@ -70,36 +70,81 @@ class Notification::PushNotificationService
|
||||
end
|
||||
|
||||
def send_fcm_push(subscription)
|
||||
return unless ENV['FCM_SERVER_KEY']
|
||||
return unless firebase_credentials_present?
|
||||
return unless subscription.fcm?
|
||||
|
||||
fcm = FCM.new(ENV.fetch('FCM_SERVER_KEY', nil))
|
||||
response = fcm.send([subscription.subscription_attributes['push_token']], fcm_options)
|
||||
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 ENV['FCM_SERVER_KEY']
|
||||
return unless ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true))
|
||||
return if firebase_credentials_present?
|
||||
return unless chatwoot_hub_enabled?
|
||||
return unless subscription.fcm?
|
||||
|
||||
ChatwootHub.send_browser_push([subscription.subscription_attributes['push_token']], fcm_options)
|
||||
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)
|
||||
subscription.destroy! if JSON.parse(response[:body])['results']&.first&.keys&.include?('error')
|
||||
end
|
||||
|
||||
def fcm_options
|
||||
def fcm_options(subscription)
|
||||
{
|
||||
notification: {
|
||||
title: notification.push_message_title,
|
||||
body: notification.push_message_body,
|
||||
sound: 'default'
|
||||
},
|
||||
android: { priority: 'high' },
|
||||
data: { notification: notification.fcm_push_data.to_json },
|
||||
collapse_key: "chatwoot_#{notification.primary_actor_type.downcase}_#{notification.primary_actor_id}"
|
||||
'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
|
||||
|
||||
@@ -204,3 +204,16 @@
|
||||
locked: false
|
||||
description: 'Disable rendering profile update page for users'
|
||||
## ------ End of Configs added for enterprise clients ------ ##
|
||||
|
||||
## ------ Configs added for FCM v1 notifications ------ ##
|
||||
- name: FIREBASE_PROJECT_ID
|
||||
display_title: 'Firebase Project ID'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Firebase project ID'
|
||||
- name: FIREBASE_CREDENTIALS
|
||||
display_title: 'Firebase Credentials'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Contents on your firebase credentials json file'
|
||||
## ------ End of Configs added for FCM v1 notifications ------ ##
|
||||
|
||||
@@ -77,8 +77,8 @@ class ChatwootHub
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
|
||||
def self.send_browser_push(fcm_token_list, fcm_options)
|
||||
info = { fcm_token_list: fcm_token_list, fcm_options: fcm_options }
|
||||
def self.send_push(fcm_options)
|
||||
info = { fcm_options: fcm_options }
|
||||
RestClient.post(PUSH_NOTIFICATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
|
||||
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
|
||||
Rails.logger.error "Exception: #{e.message}"
|
||||
|
||||
70
spec/services/notification/fcm_service_spec.rb
Normal file
70
spec/services/notification/fcm_service_spec.rb
Normal file
@@ -0,0 +1,70 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Notification::FcmService do
|
||||
let(:project_id) { 'test_project_id' }
|
||||
let(:credentials) { '{ "type": "service_account", "project_id": "test_project_id" }' }
|
||||
let(:fcm_service) { described_class.new(project_id, credentials) }
|
||||
let(:fcm_double) { instance_double(FCM) }
|
||||
let(:token_info) { { token: 'test_token', expires_at: 1.hour.from_now } }
|
||||
let(:creds_double) do
|
||||
instance_double(Google::Auth::ServiceAccountCredentials, fetch_access_token!: { 'access_token' => 'test_token', 'expires_in' => 3600 })
|
||||
end
|
||||
|
||||
before do
|
||||
allow(FCM).to receive(:new).and_return(fcm_double)
|
||||
allow(fcm_service).to receive(:generate_token).and_return(token_info)
|
||||
allow(Google::Auth::ServiceAccountCredentials).to receive(:make_creds).and_return(creds_double)
|
||||
end
|
||||
|
||||
describe '#fcm_client' do
|
||||
it 'returns an FCM client' do
|
||||
expect(fcm_service.fcm_client).to eq(fcm_double)
|
||||
expect(FCM).to have_received(:new).with('test_token', anything, project_id)
|
||||
end
|
||||
|
||||
it 'generates a new token if expired' do
|
||||
allow(fcm_service).to receive(:generate_token).and_return(token_info)
|
||||
allow(fcm_service).to receive(:token_expired?).and_return(true)
|
||||
|
||||
expect(fcm_service.fcm_client).to eq(fcm_double)
|
||||
expect(FCM).to have_received(:new).with('test_token', anything, project_id)
|
||||
expect(fcm_service).to have_received(:generate_token)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
describe '#current_token' do
|
||||
it 'returns the current token if not expired' do
|
||||
fcm_service.instance_variable_set(:@token_info, token_info)
|
||||
expect(fcm_service.send(:current_token)).to eq('test_token')
|
||||
end
|
||||
|
||||
it 'generates a new token if expired' do
|
||||
expired_token_info = { token: 'expired_token', expires_at: 1.hour.ago }
|
||||
fcm_service.instance_variable_set(:@token_info, expired_token_info)
|
||||
allow(fcm_service).to receive(:generate_token).and_return(token_info)
|
||||
|
||||
expect(fcm_service.send(:current_token)).to eq('test_token')
|
||||
expect(fcm_service).to have_received(:generate_token)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_token' do
|
||||
it 'generates a new token' do
|
||||
allow(Google::Auth::ServiceAccountCredentials).to receive(:make_creds).and_return(creds_double)
|
||||
|
||||
token = fcm_service.send(:generate_token)
|
||||
expect(token[:token]).to eq('test_token')
|
||||
expect(token[:expires_at]).to be_within(1.second).of(Time.zone.now + 3600)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#credentials_path' do
|
||||
it 'creates a StringIO with credentials' do
|
||||
string_io = fcm_service.send(:credentials_path)
|
||||
expect(string_io).to be_a(StringIO)
|
||||
expect(string_io.read).to eq(credentials)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,12 +4,15 @@ describe Notification::PushNotificationService do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account: account) }
|
||||
let!(:notification) { create(:notification, user: user, account: user.accounts.first) }
|
||||
let(:fcm_double) { double }
|
||||
let(:fcm_double) { instance_double(FCM) }
|
||||
let(:fcm_service_double) { instance_double(Notification::FcmService, fcm_client: fcm_double) }
|
||||
|
||||
before do
|
||||
allow(WebPush).to receive(:payload_send).and_return(true)
|
||||
allow(FCM).to receive(:new).and_return(fcm_double)
|
||||
allow(fcm_double).to receive(:send).and_return({ body: { 'results': [] }.to_json })
|
||||
allow(Notification::FcmService).to receive(:new).and_return(fcm_service_double)
|
||||
allow(fcm_double).to receive(:send_v1).and_return({ body: { 'results': [] }.to_json })
|
||||
allow(GlobalConfigService).to receive(:load).with('FIREBASE_PROJECT_ID', nil).and_return('test_project_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('FIREBASE_CREDENTIALS', nil).and_return('test_credentials')
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
@@ -19,16 +22,17 @@ describe Notification::PushNotificationService do
|
||||
|
||||
described_class.new(notification: notification).perform
|
||||
expect(WebPush).to have_received(:payload_send)
|
||||
expect(FCM).not_to have_received(:new)
|
||||
expect(Notification::FcmService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
it 'sends a fcm notification for firebase subscription' do
|
||||
with_modified_env FCM_SERVER_KEY: 'test', ENABLE_PUSH_RELAY_SERVER: 'false' do
|
||||
with_modified_env ENABLE_PUSH_RELAY_SERVER: 'false' do
|
||||
create(:notification_subscription, user: notification.user, subscription_type: 'fcm')
|
||||
|
||||
described_class.new(notification: notification).perform
|
||||
expect(FCM).to have_received(:new)
|
||||
expect(Notification::FcmService).to have_received(:new)
|
||||
expect(fcm_double).to have_received(:send_v1)
|
||||
expect(WebPush).not_to have_received(:payload_send)
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user