@onehilltech/blueprint-firebase-messaging
Version:
Google Firebase messaging module for Blueprint.js
215 lines (170 loc) • 6.88 kB
JavaScript
/*
* Copyright (c) 2018 One Hill Technologies, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const { Service, model, service } = require ('@onehilltech/blueprint');
const assert = require ('assert');
const gcm = require ('node-gcm');
const debug = require ('debug') ('blueprint:firebase');
const mongodb = require ('@onehilltech/blueprint-mongodb');
const mongoose = require ('mongoose');
const { Types: { ObjectId }} = mongodb;
const { get, isArray } = require ('lodash');
const { fromCallback } = require ('bluebird');
const MAX_RECIPIENTS = 1000;
const ERROR_NOT_REGISTERED = 'NotRegistered';
/**
* @class FCMService
*
* The service wrapper for Firebase Cloud Messaging.
*/
module.exports = Service.extend ({
_sender: null,
FirebaseDevice: model ('firebase-device'),
Client: model ('client'),
dryRun: true,
gatekeeper: service (),
_tokenGenerator: null,
init () {
this._super.call (this, ...arguments);
// Load the configuration, and initialize the service.
const config = this.app.lookup ('config:firebase');
assert (!!config.apiKey, 'The firebase configuration must define the apiKey property.');
this.dryRun = get (config, 'dryRun', this.dryRun);
this._sender = gcm.Sender (config.apiKey);
this._tokenGenerator = this.gatekeeper.makeNamedTokenGenerator ('firebase.device');
},
/**
* Send a message to a recipient. The recipient can be a single user, or
* a list of users.
*
* @param recipient Id, or an array of ids.
* @param msg The message the send.
* @param options Additional options for the send method.
*/
send (recipient, msg, options = {}) {
debug (`sending message to ${recipient}`);
if (!isArray (recipient))
recipient = [recipient];
// First, get all the tokens for the recipients. We have to remember that
// a recipient can have more than one token since each device owned by the
// user has its own token/registration.
const selection = {account: {$in: recipient}};
const {clients = []} = options;
// The sender has the option of restricting the message to a specific set of
// clients. If the clients option is specified, then only include users that
// have tokens for the specified clients.
return this._resolveClientIds (clients)
.then (clients => {
if (clients.length)
selection.client = {$in: clients};
return this.FirebaseDevice.find (selection)
.populate ('account', 'enabled')
.populate ('client', 'enabled').exec ();
})
.then (devices => {
// There is no need to continue if we do not have any users that match
// the target recipients.
if (!devices || devices.length === 0)
return;
// Create a new Firebase message, and only select the models where the client
// is enabled, and the user is enabled, if applicable. We want to filter the
// list before we start.
let message = new gcm.Message (Object.assign ({dryRun: this.dryRun}, msg));
let enabled = devices.filter (device => {
return !!device.token &&
(device.enabled === true || device.enabled === undefined) &&
device.client.enabled &&
(!device.account || device.account.enabled);
});
// We now need to break up the enabled group into MAX_RECIPIENTS, and then
// send the message to each group. This ensures that the message
// is not rejected by the service.
let pending = [];
for (let i = 0, len = enabled.length; i < len; i += MAX_RECIPIENTS) {
const registrationTokens = enabled.slice (i, i + MAX_RECIPIENTS).map (device => device.token);
const recipient = {registrationTokens};
pending.push (this._sendMessage (recipient, message));
}
return Promise.all (pending);
});
},
/**
* Publish a message to a topic.
*
* @param topic Target topic or condition.
* @param msg The message to send.
*/
async publish (topic, msg) {
debug (`publishing message to ${topic}`);
const message = new gcm.Message (Object.assign ({ dryRun : this.dryRun }, msg));
let recipient = {};
// If the topic begins with a slash, then set the topic on the recipient
// options. Otherwise, it is a condition.
if (topic[0] === '/')
recipient.topic = topic;
else
recipient.condition = topic;
return this._sendMessage (recipient, message);
},
/**
* Resolve the client ids from the array of clients.
*
* @param clients
* @returns {Promise<unknown[]>}
* @private
*/
_resolveClientIds (clients) {
return Promise.all (clients.map (client => {
if (mongoose.isValidObjectId (client)) {
return client instanceof ObjectId ? client : new ObjectId (client);
}
else {
// At this point, we assume the client is the name of the client. We need
// to look up the client id and use that in place of the name.
return this.Client.findOne ({ name: client }).then (client => client._id);
}
}));
},
/**
* Send the message to the recipient. The recipient is either a registered device,
* or a topic. The send() and publish() method is responsible for setting this up.
*
* @param recipient
* @param message
* @return {*}
* @private
*/
async _sendMessage (recipient, message) {
const res = await fromCallback (callback => this._sender.send (message, recipient, callback));
// Check for failures in the response. If there are no failures, then
// there is no need to continue.
if (res.failure === 0)
return;
let tasks = [];
if (recipient.registrationTokens) {
// There were some failures. We need to check what the failure is, and
// remove registration information if the id is not registered.
let badTokens = res.results.reduce ((badTokens, result, index) => {
if (result.error === ERROR_NOT_REGISTERED)
badTokens.push (recipient.registrationTokens[index]);
return badTokens;
}, []);
if (badTokens.length > 0) {
tasks.push (this.FirebaseDevice.deleteMany ({token: {$in: badTokens}}));
}
}
return Promise.all (tasks);
}
});