diff --git a/functions/index.js b/functions/index.js index ae2cdbd..9e9dcf7 100644 --- a/functions/index.js +++ b/functions/index.js @@ -1,5 +1,7 @@ 'use strict'; +const worker = require('./worker'); + const functions = require('firebase-functions'); const admin = require('firebase-admin'); @@ -7,133 +9,260 @@ admin.initializeApp(); var db = admin.firestore(); -const MAX_NOTIFICATIONS_PER_DAY = 50; +const MAX_NOTIFICATIONS_PER_DAY = 100; +//============================== +// Legacy function begin +//============================== exports.sendPushNotification = functions.https.onRequest(async (req, res) => { - if (debug()) console.log('Received payload', req.body); - var currentRateLimitDocName = getCurrentRateLimitsDocName(); - var token = req.body.push_token; - var ref = db.collection('rateLimits').doc(currentRateLimitDocName).collection('tokens').doc(token); + if (debug()) console.log('Received payload', req.body); + var currentRateLimitDocName = legacyGetCurrentRateLimitsDocName(); + 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', + var payload = { notification: { - sound: 'default', - icon: 'mini_icon', - channel_id: 'ha_notify' + 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; } - }, - 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 (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 legacyHandleError(res, 'getDoc', err); } - if(req.body.data.data) { - payload.data = req.body.data.data; + + if(docData.deliveredCount > MAX_NOTIFICATIONS_PER_DAY) { + return res.status(429).json({ + 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), + }); } - if(req.body.data.webpush) { - payload.webpush = req.body.data.webpush; + + 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 legacyHandleError(res, 'sendNotification', err); } - } - if (debug()) console.log('Notification payload', JSON.stringify(payload)); + if (debug()) console.log('Successfully sent message:', messageId); - 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), - }); + return res.status(201).json({ + messageId: messageId, + sentPayload: payload, + target: token, + rateLimits: getRateLimitsObject(docData), + }); }); async function setRateLimitDoc(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); + 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 legacyHandleError(res, 'setDocument', err); } - } catch(err) { - if(docExists) { - console.error('Error updating document!', err); - } else { - console.error('Error creating document!', err); - } - return handleError(res, 'setDocument', err); - } - return true; + return true; } -function handleError(res, step, incomingError) { - if (!incomingError) return null; - console.error('InternalError during', step, incomingError); - return res.status(500).send({ - errorType: 'InternalError', - errorStep: step, - message: incomingError.message, - }); +function legacyGetCurrentRateLimitsDocName() { + var today = new Date(); + var dd = String(today.getDate()).padStart(2, '0'); + var mm = String(today.getMonth() + 1).padStart(2, '0'); + var yyyy = today.getFullYear(); + return yyyy + mm + dd; } -function getCurrentRateLimitsDocName() { +function legacyHandleError(res, step, incomingError) { + if (!incomingError) return null; + console.error('InternalError during', step, incomingError); + return res.status(500).json({ + errorType: 'InternalError', + errorStep: step, + message: incomingError.message, + }); +} +//============================== +// Legacy function end +//============================== +exports.pushNotifyV2 = functions.https.onRequest(async (req, res) => { + return handleRequest(req, res, worker.createPayload); +}); + +async function handleRequest(req, res, payloadHandler) { + if (debug()) console.log('Received payload', req.body); + var today = getToday(); + var token = req.body.push_token; + if (!token) { + return res.status(403).json({ 'errorMessage': 'Missed token' }); + } + if (token.indexOf(':') === -1) { + return res.status(403).json({'errorMessage': 'Invalid token'}); + } + + var workerResult = payloadHandler(req); + 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).json({ + 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).json({ + 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, payload, step, incomingError) { + if (!incomingError) return null; + if (payload) { + console.error('InternalError during', step, 'with payload', JSON.stringify(payload), incomingError); + } else { + console.error('InternalError during', step, incomingError); + } + return res.status(500).json({ + errorType: 'InternalError', + errorStep: step, + message: incomingError.message, + }); +} + +function getToday() { var today = new Date(); var dd = String(today.getDate()).padStart(2, '0'); var mm = String(today.getMonth() + 1).padStart(2, '0'); @@ -142,15 +271,15 @@ function getCurrentRateLimitsDocName() { } function getRateLimitsObject(doc) { - var d = new Date(); - return { - successful: (doc.deliveredCount || 0), - errors: (doc.errorCount || 0), - total: (doc.totalCount || 0), - maximum: MAX_NOTIFICATIONS_PER_DAY, - remaining: (MAX_NOTIFICATIONS_PER_DAY - doc.deliveredCount), - resetsAt: new Date(d.getFullYear(), d.getMonth(), d.getDate()+1) - }; + var d = new Date(); + return { + successful: (doc.deliveredCount || 0), + errors: (doc.errorCount || 0), + total: (doc.totalCount || 0), + maximum: MAX_NOTIFICATIONS_PER_DAY, + remaining: (MAX_NOTIFICATIONS_PER_DAY - doc.deliveredCount), + resetsAt: new Date(d.getFullYear(), d.getMonth(), d.getDate()+1) + }; } function debug() { diff --git a/functions/payloader.js b/functions/payloader.js deleted file mode 100644 index 5ff1874..0000000 --- a/functions/payloader.js +++ /dev/null @@ -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 }; - } -} \ No newline at end of file diff --git a/functions/worker.js b/functions/worker.js new file mode 100644 index 0000000..7070b6b --- /dev/null +++ b/functions/worker.js @@ -0,0 +1,41 @@ +module.exports = { + createPayload: function createPayload(req) { + // Payload according to + // https://firebase.google.com/docs/reference/admin/node/TokenMessage + var payload = { + android: { + collapseKey: "haclient", + notification: { + icon: "mini_icon", + clickAction: "FLUTTER_NOTIFICATION_CLICK", + channelId: "ha_notify" + } + }, + notification: {}, + data: {}, + }; + var updateRateLimits = true; + + if (req.body.message) { + payload.notification.body = req.body.message; + } + + if (req.body.title) { + payload.notification.title = req.body.title; + } + + // https://firebase.google.com/docs/reference/admin/node/admin.messaging.AndroidNotification + if (req.body.data) { + if (req.body.data.image) { + payload.notification.imageUrl = req.body.data.image; + } + for (const key of ['tag', 'color', 'channelId']) { + if (req.body.data[key]) { + payload.android.notification[key] = String(req.body.data[key]) + } + } + } + + return { updateRateLimits: updateRateLimits, payload: payload }; + } +} \ No newline at end of file