Prepare for actionable notifications

This commit is contained in:
Yegor Vialov 2020-05-16 19:20:08 +00:00
parent e231037f7f
commit 41a538259c
3 changed files with 236 additions and 196 deletions

View File

@ -1,5 +1,7 @@
'use strict'; 'use strict';
const worker = require('./worker');
const functions = require('firebase-functions'); const functions = require('firebase-functions');
const admin = require('firebase-admin'); const admin = require('firebase-admin');
@ -7,133 +9,128 @@ admin.initializeApp();
var db = admin.firestore(); var db = admin.firestore();
const MAX_NOTIFICATIONS_PER_DAY = 50; const MAX_NOTIFICATIONS_PER_DAY = 100;
exports.sendPushNotification = functions.https.onRequest(async (req, res) => { exports.sendPushNotification = functions.https.onRequest(async (req, res) => {
if (debug()) console.log('Received payload', req.body); return handleRequest(req, res, worker.createLegacyPayload);
var currentRateLimitDocName = getCurrentRateLimitsDocName();
var token = req.body.push_token;
var ref = db.collection('rateLimits').doc(currentRateLimitDocName).collection('tokens').doc(token);
var payload = {
notification: {
body: req.body.message
},
android: {
priority: 'HIGH',
notification: {
sound: 'default',
icon: 'mini_icon',
channel_id: 'ha_notify'
}
},
token: token,
};
if(req.body.title) {
payload.notification.title = req.body.title;
}
if(req.body.data) {
if(req.body.data.android) {
payload.android = req.body.data.android;
}
if(req.body.data.apns) {
payload.apns = req.body.data.apns;
}
if(req.body.data.data) {
payload.data = req.body.data.data;
}
if(req.body.data.webpush) {
payload.webpush = req.body.data.webpush;
}
}
if (debug()) console.log('Notification payload', JSON.stringify(payload));
var docExists = false;
var docData = {
deliveredCount: 0,
errorCount: 0,
totalCount: 0,
};
try {
let currentDoc = await ref.get();
docExists = currentDoc.exists;
if(currentDoc.exists) {
docData = currentDoc.data();
}
} catch(err) {
console.error('Error getting document!', err);
return handleError(res, 'getDoc', err);
}
if(docData.deliveredCount > MAX_NOTIFICATIONS_PER_DAY) {
return res.status(429).send({
errorType: 'RateLimited',
message: 'The given target has reached the maximum number of notifications allowed per day. Please try again later.',
target: token,
rateLimits: getRateLimitsObject(docData),
});
}
docData.totalCount = docData.totalCount + 1;
var messageId;
try {
messageId = await admin.messaging().send(payload);
docData.deliveredCount = docData.deliveredCount + 1;
} catch(err) {
docData.errorCount = docData.errorCount + 1;
await setRateLimitDoc(ref, docExists, docData, res);
return handleError(res, 'sendNotification', err);
}
if (debug()) console.log('Successfully sent message:', messageId);
await setRateLimitDoc(ref, docExists, docData, res);
return res.status(201).send({
messageId: messageId,
sentPayload: payload,
target: token,
rateLimits: getRateLimitsObject(docData),
});
}); });
async function setRateLimitDoc(ref, docExists, docData, res) { exports.pushNotifyV2 = functions.https.onRequest(async (req, res) => {
try { return handleRequest(req, res, worker.createPayload);
if(docExists) { });
if (debug()) console.log('Updating existing doc!');
await ref.update(docData); async function handleRequest(req, res, payloadHandler) {
} else { if (debug()) console.log('Received payload', req.body);
if (debug()) console.log('Creating new doc!'); var today = getToday();
await ref.set(docData); var token = req.body.push_token;
if (!token) {
return res.status(403).send({ 'errorMessage': 'Missed token' });
} }
} catch(err) { if (token.indexOf(':') === -1) {
if(docExists) { return res.status(403).send({'errorMessage': 'Invalid token'});
console.error('Error updating document!', err);
} else {
console.error('Error creating document!', err);
} }
return handleError(res, 'setDocument', err);
} var workerResult = payloadHandler(req);
return true; var updateRateLimits = workerResult.updateRateLimits;
var payload = workerResult.payload;
payload['token'] = token;
var ref = db.collection('rateLimitsV2').doc(today).collection('tokens').doc(token);
var docExists = false;
var docData = {
deliveredCount: 0,
errorCount: 0,
totalCount: 0,
};
try {
var currentDoc = await ref.get();
docExists = currentDoc.exists;
if (currentDoc.exists) {
docData = currentDoc.data();
}
} catch(err) {
console.error('Error getting document', err);
return handleError(res, 'getDoc', err);
}
if (updateRateLimits && docData.deliveredCount > MAX_NOTIFICATIONS_PER_DAY) {
return res.status(429).send({
errorType: 'RateLimited',
message: 'You have exited the maximum number of notifications allowed per day. Please try again later.',
target: token,
rateLimits: getRateLimitsObject(docData),
});
}
docData.totalCount = docData.totalCount + 1;
if (debug) console.log('Sending payload', JSON.stringify(payload));
var messageId;
try {
messageId = await admin.messaging().send(payload);
docData.deliveredCount = docData.deliveredCount + 1;
} catch(err) {
docData.errorCount = docData.errorCount + 1;
await writeRateLimits(ref, docExists, docData, res);
return handleError(res, payload, 'sendNotification', err);
}
if (debug()) console.log('Successfully sent message:', messageId);
if (updateRateLimits) {
await writeRateLimits(ref, docExists, docData, res);
} else {
if (debug) console.log('Not updating rate limits because notification is critical or command');
}
return res.status(201).send({
messageId: messageId,
sentPayload: payload,
target: token,
rateLimits: getRateLimitsObject(docData),
});
};
async function writeRateLimits(ref, docExists, docData, res) {
try {
if (docExists) {
if (debug()) console.log('Updating existing doc');
await ref.update(docData);
} else {
if (debug()) console.log('Creating new doc');
await ref.set(docData);
}
} catch(err) {
if (docExists) {
console.error('Error updating document', err);
} else {
console.error('Error creating document', err);
}
return handleError(res, 'setDocument', err);
}
return true;
} }
function handleError(res, step, incomingError) { function handleError(res, payload, step, incomingError) {
if (!incomingError) return null; if (!incomingError) return null;
console.error('InternalError during', step, incomingError); if (payload) {
return res.status(500).send({ console.error('InternalError during', step, 'with payload', JSON.stringify(payload), incomingError);
errorType: 'InternalError', } else {
errorStep: step, console.error('InternalError during', step, incomingError);
message: incomingError.message, }
}); return res.status(500).send({
errorType: 'InternalError',
errorStep: step,
message: incomingError.message,
});
} }
function getCurrentRateLimitsDocName() { function getToday() {
var today = new Date(); var today = new Date();
var dd = String(today.getDate()).padStart(2, '0'); var dd = String(today.getDate()).padStart(2, '0');
var mm = String(today.getMonth() + 1).padStart(2, '0'); var mm = String(today.getMonth() + 1).padStart(2, '0');
@ -142,15 +139,17 @@ function getCurrentRateLimitsDocName() {
} }
function getRateLimitsObject(doc) { function getRateLimitsObject(doc) {
var d = new Date(); var d = new Date();
return { var remainingCount = (MAX_NOTIFICATIONS_PER_DAY - doc.deliveredCount);
successful: (doc.deliveredCount || 0), if (remainingCount === -1) remainingCount = 0;
errors: (doc.errorCount || 0), return {
total: (doc.totalCount || 0), successful: (doc.deliveredCount || 0),
maximum: MAX_NOTIFICATIONS_PER_DAY, errors: (doc.errorCount || 0),
remaining: (MAX_NOTIFICATIONS_PER_DAY - doc.deliveredCount), total: (doc.totalCount || 0),
resetsAt: new Date(d.getFullYear(), d.getMonth(), d.getDate()+1) maximum: MAX_NOTIFICATIONS_PER_DAY,
}; remaining: remainingCount,
resetsAt: new Date(d.getFullYear(), d.getMonth(), d.getDate()+1)
};
} }
function debug() { function debug() {

View File

@ -1,70 +0,0 @@
module.exports = {
createPayload: function createPayload(req) {
let payload = {
android: {},
data: {},
fcm_options: {
analytics_label: "androidV1Notification"
}
};
let updateRateLimits = true;
if(req.body.data){
// Handle the web actions by changing them into a format the app can handle
// https://www.home-assistant.io/integrations/html5/#actions
if(req.body.data.actions) {
for (let i = 0; i < req.body.data.actions.length; i++) {
const action = req.body.data.actions[i];
if(action.action){
payload.data["action_"+(i+1)+"_key"] = action.action
}
if(action.title) {
payload.data["action_"+(i+1)+"_title"] = action.title
}
if(action.uri){
payload.data["action_"+(i+1)+"_uri"] = action.uri
}
}
}
// Allow setting of ttl
// https://firebase.google.com/docs/reference/admin/node/admin.messaging.AndroidConfig.html#optional-ttl
if(req.body.data.ttl){
payload.android.ttl = req.body.data.ttl
}
// https://firebase.google.com/docs/reference/admin/node/admin.messaging.AndroidConfig.html#optional-priority
if(req.body.data.priority){
payload.android.priority = req.body.data.priority
}
// https://firebase.google.com/docs/reference/admin/node/admin.messaging.AndroidNotification.html
for (const key of [
'icon', 'color', 'sound', 'tag', 'clickAction',
'bodyLocKey', 'bodyLocArgs', 'titleLocKey', 'titleLocArgs', 'channelId',
'ticker', 'sticky', 'eventTime', 'localOnly', 'notificationPriority',
'defaultSound', 'defaultVibrateTimings', 'defaultLightSettings', 'vibrateTimings',
'visibility', 'notificationCount', 'lightSettings', 'image'
]) {
if(req.body.data[key]){
payload.data[key] = String(req.body.data[key])
}
}
}
// Always put message, title, and image in data so that the application can handle creating
// the notifications. This allows us to safely create actionable/imaged notifications.
if(req.body.message) {
payload.data.message = req.body.message
if(req.body.message in ['request_location_update', 'clear_notification']) {
updateRateLimits = false
}
}
if(req.body.title) {
payload.data.title = req.body.title
}
return { updateRateLimits: updateRateLimits, payload: payload };
}
}

111
functions/worker.js Normal file
View File

@ -0,0 +1,111 @@
module.exports = {
createLegacyPayload: function createLegacyPayload(req) {
var payload = {
notification: {
body: req.body.message
},
android: {
priority: 'HIGH',
notification: {
sound: 'default',
icon: 'mini_icon',
channel_id: 'ha_notify'
}
},
token: token,
};
if(req.body.title) {
payload.notification.title = req.body.title;
}
if(req.body.data) {
if(req.body.data.android) {
payload.android = req.body.data.android;
}
if(req.body.data.apns) {
payload.apns = req.body.data.apns;
}
if(req.body.data.data) {
payload.data = req.body.data.data;
}
if(req.body.data.webpush) {
payload.webpush = req.body.data.webpush;
}
}
if (debug()) console.log('Notification payload', JSON.stringify(payload));
return { updateRateLimits: true, payload: payload };
},
createPayload: function createPayload(req) {
let payload = {
android: {},
data: {},
fcm_options: {
analytics_label: "androidV1Notification"
}
};
let updateRateLimits = true;
if(req.body.data){
// Handle the web actions by changing them into a format the app can handle
// https://www.home-assistant.io/integrations/html5/#actions
if(req.body.data.actions) {
for (let i = 0; i < req.body.data.actions.length; i++) {
const action = req.body.data.actions[i];
if(action.action){
payload.data["action_"+(i+1)+"_key"] = action.action
}
if(action.title) {
payload.data["action_"+(i+1)+"_title"] = action.title
}
if(action.uri){
payload.data["action_"+(i+1)+"_uri"] = action.uri
}
}
}
// Allow setting of ttl
// https://firebase.google.com/docs/reference/admin/node/admin.messaging.AndroidConfig.html#optional-ttl
if(req.body.data.ttl){
payload.android.ttl = req.body.data.ttl
}
// https://firebase.google.com/docs/reference/admin/node/admin.messaging.AndroidConfig.html#optional-priority
if(req.body.data.priority){
payload.android.priority = req.body.data.priority
}
// https://firebase.google.com/docs/reference/admin/node/admin.messaging.AndroidNotification.html
for (const key of [
'icon', 'color', 'sound', 'tag', 'clickAction',
'bodyLocKey', 'bodyLocArgs', 'titleLocKey', 'titleLocArgs', 'channelId',
'ticker', 'sticky', 'eventTime', 'localOnly', 'notificationPriority',
'defaultSound', 'defaultVibrateTimings', 'defaultLightSettings', 'vibrateTimings',
'visibility', 'notificationCount', 'lightSettings', 'image'
]) {
if(req.body.data[key]){
payload.data[key] = String(req.body.data[key])
}
}
}
// Always put message, title, and image in data so that the application can handle creating
// the notifications. This allows us to safely create actionable/imaged notifications.
if(req.body.message) {
payload.data.message = req.body.message
if(req.body.message in ['request_location_update', 'clear_notification']) {
updateRateLimits = false
}
}
if(req.body.title) {
payload.data.title = req.body.title
}
return { updateRateLimits: updateRateLimits, payload: payload };
}
}