Files
upstream-rocketchat/apps/meteor/server/lib/notifications/push/fcm.ts
upstream-mirror 30810eecd7
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
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

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 });
});
}
};