require "faraday" require "cgi" require "json" require "googleauth" class FCM BASE_URI = "https://fcm.googleapis.com" BASE_URI_V1 = "https://fcm.googleapis.com/v1/projects/" DEFAULT_TIMEOUT = 30 GROUP_NOTIFICATION_BASE_URI = "https://android.googleapis.com" INSTANCE_ID_API = "https://iid.googleapis.com" TOPIC_REGEX = /[a-zA-Z0-9\-_.~%]+/ def initialize(json_key_path = "", project_name = "", http_options = {}) @json_key_path = json_key_path @project_name = project_name @http_options = http_options end # See https://firebase.google.com/docs/cloud-messaging/send-message # { # "token": "4sdsx", # "notification": { # "title": "Breaking News", # "body": "New news story available." # }, # "data": { # "story_id": "story_12345" # }, # "android": { # "notification": { # "click_action": "TOP_STORY_ACTIVITY", # "body": "Check out the Top Story" # } # }, # "apns": { # "payload": { # "aps": { # "category" : "NEW_MESSAGE_CATEGORY" # } # } # } # } # fcm = FCM.new(json_key_path, project_name) # fcm.send_v1( # { "token": "4sdsx",, "to" : "notification": {}.. } # ) def send_notification_v1(message) return if @project_name.empty? post_body = { 'message': message } for_uri(BASE_URI_V1) do |connection| response = connection.post( "#{@project_name}/messages:send", post_body.to_json ) build_response(response) end end alias send_v1 send_notification_v1 def create_notification_key(key_name, project_id, registration_ids = []) post_body = build_post_body(registration_ids, operation: "create", notification_key_name: key_name) extra_headers = { "project_id" => project_id, } for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection| response = connection.post("/gcm/notification", post_body.to_json) build_response(response) end end alias create create_notification_key def add_registration_ids(key_name, project_id, notification_key, registration_ids) post_body = build_post_body(registration_ids, operation: "add", notification_key_name: key_name, notification_key: notification_key) extra_headers = { "project_id" => project_id, } for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection| response = connection.post("/gcm/notification", post_body.to_json) build_response(response) end end alias add add_registration_ids def remove_registration_ids(key_name, project_id, notification_key, registration_ids) post_body = build_post_body(registration_ids, operation: "remove", notification_key_name: key_name, notification_key: notification_key) extra_headers = { "project_id" => project_id, } for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection| response = connection.post("/gcm/notification", post_body.to_json) build_response(response) end end alias remove remove_registration_ids def recover_notification_key(key_name, project_id) params = { notification_key_name: key_name } extra_headers = { "project_id" => project_id, } for_uri(GROUP_NOTIFICATION_BASE_URI, extra_headers) do |connection| response = connection.get("/gcm/notification", params) build_response(response) end end def topic_subscription(topic, registration_token) for_uri(INSTANCE_ID_API) do |connection| response = connection.post( "/iid/v1/#{registration_token}/rel/topics/#{topic}" ) build_response(response) end end def topic_unsubscription(topic, registration_token) batch_topic_unsubscription(topic, [registration_token]) end def batch_topic_subscription(topic, registration_tokens) manage_topics_relationship(topic, registration_tokens, 'Add') end def batch_topic_unsubscription(topic, registration_tokens) manage_topics_relationship(topic, registration_tokens, 'Remove') end def manage_topics_relationship(topic, registration_tokens, action) body = { to: "/topics/#{topic}", registration_tokens: registration_tokens } for_uri(INSTANCE_ID_API) do |connection| response = connection.post("/iid/v1:batch#{action}", body.to_json) build_response(response) end end def get_instance_id_info(iid_token, options = {}) params = options for_uri(INSTANCE_ID_API) do |connection| response = connection.get("/iid/info/#{iid_token}", params) build_response(response) end end def send_to_topic(topic, options = {}) if topic.gsub(TOPIC_REGEX, '').length.zero? body = { 'message': { 'topic': topic }.merge(options) } for_uri(BASE_URI_V1) do |connection| response = connection.post( "#{@project_name}/messages:send", body.to_json ) build_response(response) end end end def send_to_topic_condition(condition, options = {}) if validate_condition?(condition) body = { 'message': { 'condition': condition }.merge(options) } for_uri(BASE_URI_V1) do |connection| response = connection.post( "#{@project_name}/messages:send", body.to_json ) build_response(response) end end end private def for_uri(uri, extra_headers = {}) connection = ::Faraday.new( url: uri, request: { timeout: @http_options.fetch(:timeout, DEFAULT_TIMEOUT) } ) do |faraday| faraday.adapter Faraday.default_adapter faraday.headers["Content-Type"] = "application/json" faraday.headers["Authorization"] = "Bearer #{jwt_token}" faraday.headers["access_token_auth"]= "true" extra_headers.each do |key, value| faraday.headers[key] = value end end yield connection end def build_post_body(registration_ids, options = {}) ids = registration_ids.is_a?(String) ? [registration_ids] : registration_ids { registration_ids: ids }.merge(options) end def build_response(response, registration_ids = []) body = response.body || {} response_hash = { body: body, headers: response.headers, status_code: response.status } case response.status when 200 response_hash[:response] = "success" body = JSON.parse(body) unless body.empty? response_hash[:canonical_ids] = build_canonical_ids(body, registration_ids) unless registration_ids.empty? response_hash[:not_registered_ids] = build_not_registered_ids(body, registration_ids) unless registration_ids.empty? when 400 response_hash[:response] = "Only applies for JSON requests. Indicates that the request could not be parsed as JSON, or it contained invalid fields." when 401 response_hash[:response] = "There was an error authenticating the sender account." when 503 response_hash[:response] = "Server is temporarily unavailable." when 500..599 response_hash[:response] = "There was an internal error in the FCM server while trying to process the request." end response_hash end def build_canonical_ids(body, registration_ids) canonical_ids = [] unless body.empty? if body["canonical_ids"] > 0 body["results"].each_with_index do |result, index| canonical_ids << { old: registration_ids[index], new: result["registration_id"] } if has_canonical_id?(result) end end end canonical_ids end def build_not_registered_ids(body, registration_id) not_registered_ids = [] unless body.empty? if body["failure"] > 0 body["results"].each_with_index do |result, index| not_registered_ids << registration_id[index] if is_not_registered?(result) end end end not_registered_ids end def has_canonical_id?(result) !result["registration_id"].nil? end def is_not_registered?(result) result["error"] == "NotRegistered" end def validate_condition?(condition) validate_condition_format?(condition) && validate_condition_topics?(condition) end def validate_condition_format?(condition) bad_characters = condition.gsub( /(topics|in|\s|\(|\)|(&&)|[!]|(\|\|)|'([a-zA-Z0-9\-_.~%]+)')/, "" ) bad_characters.length == 0 end def validate_condition_topics?(condition) topics = condition.scan(/(?:^|\S|\s)'([^']*?)'(?:$|\S|\s)/).flatten topics.all? { |topic| topic.gsub(TOPIC_REGEX, "").length == 0 } end def jwt_token scope = "https://www.googleapis.com/auth/firebase.messaging" @authorizer ||= Google::Auth::ServiceAccountCredentials.make_creds( json_key_io: json_key, scope: scope, ) token = @authorizer.fetch_access_token! token["access_token"] end def json_key @json_key ||= if @json_key_path.respond_to?(:read) @json_key_path else File.open(@json_key_path) end end end