Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
204 lines
5.9 KiB
TypeScript
204 lines
5.9 KiB
TypeScript
import { serverFetch as fetch, type ExtendedFetchOptions } from '@rocket.chat/server-fetch';
|
|
import EJSON from 'ejson';
|
|
import type { Response } from 'node-fetch';
|
|
|
|
import type { PendingPushNotification } from './definition';
|
|
import { logger } from './logger';
|
|
import type { NativeNotificationParameters } from './push';
|
|
|
|
type FCMDataField = Record<string, any>;
|
|
|
|
type FCMNotificationField = {
|
|
title?: string;
|
|
body?: string;
|
|
image?: string;
|
|
};
|
|
|
|
type FCMMessage = {
|
|
notification?: FCMNotificationField;
|
|
data?: FCMDataField;
|
|
token?: string;
|
|
to?: string;
|
|
android?: {
|
|
collapseKey?: string;
|
|
priority?: 'HIGH' | 'NORMAL';
|
|
ttl?: string;
|
|
restrictedPackageName?: string;
|
|
data?: FCMDataField;
|
|
notification?: FCMNotificationField;
|
|
fcm_options?: {
|
|
analytics_label?: string;
|
|
};
|
|
direct_boot_ok?: boolean;
|
|
};
|
|
webpush?: {
|
|
headers?: FCMDataField;
|
|
data?: FCMDataField;
|
|
notification?: FCMNotificationField;
|
|
fcm_options?: {
|
|
link?: string;
|
|
analytics_label?: string;
|
|
};
|
|
};
|
|
fcm_options?: {
|
|
analytics_label?: string;
|
|
};
|
|
};
|
|
|
|
// https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode
|
|
type FCMError = {
|
|
error: {
|
|
code: number;
|
|
message: string;
|
|
status: string;
|
|
};
|
|
};
|
|
|
|
// The 403 error code is used when the server is refusing to process a request to a specific token due to
|
|
// a SENDER_ID_MISMATCH error. This error is returned when the sender ID provided in the request does not match the sender ID
|
|
// associated with the registration token.
|
|
const SENDER_ID_MISMATCH_ERROR_CODE = 403;
|
|
const NOT_FOUND_ERROR_CODE = 404;
|
|
|
|
/**
|
|
* Send a push notification using Firebase Cloud Messaging (FCM).
|
|
* implements the Firebase Cloud Messaging HTTP v1 API, and all of its retry logic,
|
|
* see: https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode
|
|
*
|
|
* Errors:
|
|
* - For 400, 401 errors: abort, and do not retry.
|
|
* - For 404 errors: remove the token from the database.
|
|
* - For 429 errors: retry after waiting for the duration set in the retry-after header. If no retry-after header is set, default to 60 seconds.
|
|
* - For 5xx errors: retry with exponential backoff.
|
|
*/
|
|
async function fetchWithRetry(url: string, _removeToken: () => void, options: ExtendedFetchOptions, retries = 0): Promise<Response> {
|
|
const MAX_RETRIES = 5;
|
|
const response = await fetch(url, options);
|
|
|
|
if (response.ok) {
|
|
return response;
|
|
}
|
|
|
|
if (retries >= MAX_RETRIES) {
|
|
logger.error('sendFCM error: max retries reached');
|
|
return response;
|
|
}
|
|
|
|
const retryAfter = response.headers.get('retry-after');
|
|
const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : 60;
|
|
if ([SENDER_ID_MISMATCH_ERROR_CODE, NOT_FOUND_ERROR_CODE].includes(response.status)) {
|
|
_removeToken();
|
|
return response;
|
|
}
|
|
|
|
if (response.status === 429) {
|
|
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000));
|
|
return fetchWithRetry(url, _removeToken, options, retries + 1);
|
|
}
|
|
|
|
if (response.status >= 500 && response.status < 600) {
|
|
const backoff = Math.pow(2, retries) * 10000;
|
|
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
return fetchWithRetry(url, _removeToken, options, retries + 1);
|
|
}
|
|
|
|
const error: FCMError = await response.json();
|
|
logger.error({ msg: 'sendFCM error', err: error });
|
|
|
|
return response;
|
|
}
|
|
|
|
function getFCMMessagesFromPushData(userTokens: string[], notification: PendingPushNotification): { message: FCMMessage }[] {
|
|
// first we will get the `data` field from the notification
|
|
const data: FCMDataField = notification.payload ? { ejson: EJSON.stringify(notification.payload) } : {};
|
|
|
|
// Set image
|
|
if (notification.gcm?.image) {
|
|
data.image = notification.gcm?.image;
|
|
}
|
|
|
|
// Set extra details
|
|
if (notification.badge) {
|
|
data.msgcnt = notification.badge.toString();
|
|
}
|
|
|
|
if (notification.sound) {
|
|
data.soundname = notification.sound;
|
|
}
|
|
|
|
if (notification.notId) {
|
|
data.notId = notification.notId.toString();
|
|
}
|
|
|
|
if (notification.gcm?.style) {
|
|
data.style = notification.gcm?.style;
|
|
}
|
|
|
|
if (notification.contentAvailable) {
|
|
data['content-available'] = notification.contentAvailable.toString();
|
|
}
|
|
|
|
// then we will create the notification field
|
|
const notificationField: FCMNotificationField = {
|
|
...(notification.title && { title: notification.title }),
|
|
...(notification.text && { body: notification.text }),
|
|
};
|
|
|
|
// then we will create the message
|
|
const message: FCMMessage = {
|
|
...(Object.keys(notificationField).length && { notification: notificationField }),
|
|
data,
|
|
android: {
|
|
priority: 'HIGH',
|
|
},
|
|
};
|
|
|
|
// then we will create the message for each token
|
|
return userTokens.map((token) => ({ message: { ...message, token } }));
|
|
}
|
|
|
|
export const sendFCM = function ({ userTokens, notification, _removeToken, options }: NativeNotificationParameters): void {
|
|
const tokens = typeof userTokens === 'string' ? [userTokens] : userTokens;
|
|
if (!tokens.length) {
|
|
logger.log('sendFCM no push tokens found');
|
|
return;
|
|
}
|
|
|
|
logger.debug({ msg: 'sendFCM', tokens, notification });
|
|
|
|
const messages = getFCMMessagesFromPushData(tokens, notification);
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${options.gcm.apiKey}`,
|
|
'access_token_auth': true,
|
|
} as Record<string, any>;
|
|
|
|
if (!options.gcm.projectNumber.trim()) {
|
|
logger.error('sendFCM error: GCM project number is missing');
|
|
return;
|
|
}
|
|
|
|
const url = `https://fcm.googleapis.com/v1/projects/${options.gcm.projectNumber}/messages:send`;
|
|
|
|
for (const fcmRequest of messages) {
|
|
logger.debug({ msg: 'sendFCM message', request: fcmRequest });
|
|
|
|
const removeToken = () => {
|
|
const { token } = fcmRequest.message;
|
|
token && _removeToken(token);
|
|
};
|
|
|
|
const response = fetchWithRetry(url, removeToken, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(fcmRequest),
|
|
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
|
|
ignoreSsrfValidation: true,
|
|
});
|
|
|
|
response.catch((err) => {
|
|
logger.error({ msg: 'sendFCM error', err });
|
|
});
|
|
}
|
|
};
|