diff --git a/apps/meteor/app/api/server/v1/push.ts b/apps/meteor/app/api/server/v1/push.ts index 2c72d37d7e5b3..3fe6765393ecf 100644 --- a/apps/meteor/app/api/server/v1/push.ts +++ b/apps/meteor/app/api/server/v1/push.ts @@ -1,4 +1,5 @@ import { Push } from '@rocket.chat/core-services'; +import { pushTokenTypes } from '@rocket.chat/core-typings'; import type { IPushToken, IPushTokenTypes } from '@rocket.chat/core-typings'; import { Messages, PushToken, Users, Rooms, Settings } from '@rocket.chat/models'; import { @@ -38,7 +39,7 @@ const PushTokenPOSTSchema: JSONSchemaType = { }, type: { type: 'string', - enum: ['apn', 'gcm'], + enum: pushTokenTypes, }, value: { type: 'string', @@ -148,6 +149,7 @@ const pushTokenEndpoints = API.v1 }, voipToken: { type: 'string', + nullable: true, }, }, additionalProperties: false, diff --git a/apps/meteor/app/push/server/apn.ts b/apps/meteor/app/push/server/apn.ts index e8732a9daae5f..d5d4415d61316 100644 --- a/apps/meteor/app/push/server/apn.ts +++ b/apps/meteor/app/push/server/apn.ts @@ -1,5 +1,5 @@ import apn from '@parse/node-apn'; -import type { IPushToken, RequiredField } from '@rocket.chat/core-typings'; +import type { RequiredField } from '@rocket.chat/core-typings'; import EJSON from 'ejson'; import type { PushOptions, PendingPushNotification } from './definition'; @@ -24,7 +24,7 @@ export const sendAPN = ({ }: { userToken: string; notification: PendingPushNotification & { topic: string }; - _removeToken: (token: IPushToken['token']) => void; + _removeToken: (token: string) => void; }) => { if (!apnConnection) { throw new Error('Apn Connection not initialized.'); @@ -34,7 +34,15 @@ export const sendAPN = ({ const note = new apn.Notification(); - note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now. + // Expires 1 hour from now, unless configured otherwise. + const expirationSeconds = notification.apn?.expirationSeconds ?? 3600; + + if (notification.useVoipToken) { + note.pushType = 'voip'; + } + + note.expiry = Math.floor(Date.now() / 1000) + expirationSeconds; + if (notification.badge !== undefined) { note.badge = notification.badge; } @@ -50,10 +58,16 @@ export const sendAPN = ({ // adds category support for iOS8 custom actions as described here: // https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/ // RemoteNotificationsPG/Chapters/IPhoneOSClientImp.html#//apple_ref/doc/uid/TP40008194-CH103-SW36 - note.category = notification.apn?.category; + if (notification.apn?.category) { + note.category = notification.apn.category; + } - note.body = notification.text; - note.title = notification.title; + if (notification.text) { + note.body = notification.text; + } + if (notification.title) { + note.title = notification.title; + } if (notification.notId != null) { note.threadId = String(notification.notId); @@ -62,7 +76,9 @@ export const sendAPN = ({ // Allow the user to set payload data note.payload = notification.payload ? { ejson: EJSON.stringify(notification.payload) } : {}; - note.payload.messageFrom = notification.from; + if (notification.from) { + note.payload.messageFrom = notification.from; + } note.priority = priority; note.topic = notification.topic; @@ -81,9 +97,7 @@ export const sendAPN = ({ msg: 'Removing APN token', token: userToken, }); - _removeToken({ - apn: userToken, - }); + _removeToken(userToken); } }); }); diff --git a/apps/meteor/app/push/server/definition.ts b/apps/meteor/app/push/server/definition.ts index c849d06c11b88..d194f66664c68 100644 --- a/apps/meteor/app/push/server/definition.ts +++ b/apps/meteor/app/push/server/definition.ts @@ -18,14 +18,15 @@ export type PushOptions = { }; export type PendingPushNotification = { - from: string; - title: string; - text: string; + from?: string; + title?: string; + text?: string; badge?: number; sound?: string; notId?: number; apn?: { category?: string; + expirationSeconds?: number; }; gcm?: { style?: string; @@ -42,4 +43,5 @@ export type PendingPushNotification = { priority?: number; contentAvailable?: 1 | 0; + useVoipToken?: boolean; }; diff --git a/apps/meteor/app/push/server/fcm.ts b/apps/meteor/app/push/server/fcm.ts index 9a3529d02e1f4..afe365784ef03 100644 --- a/apps/meteor/app/push/server/fcm.ts +++ b/apps/meteor/app/push/server/fcm.ts @@ -9,8 +9,8 @@ import type { NativeNotificationParameters } from './push'; type FCMDataField = Record; type FCMNotificationField = { - title: string; - body: string; + title?: string; + body?: string; image?: string; }; @@ -140,13 +140,13 @@ function getFCMMessagesFromPushData(userTokens: string[], notification: PendingP // then we will create the notification field const notificationField: FCMNotificationField = { - title: notification.title, - body: notification.text, + ...(notification.title && { title: notification.title }), + ...(notification.text && { body: notification.text }), }; // then we will create the message const message: FCMMessage = { - notification: notificationField, + ...(Object.keys(notificationField).length && { notification: notificationField }), data, android: { priority: 'HIGH', @@ -185,7 +185,7 @@ export const sendFCM = function ({ userTokens, notification, _removeToken, optio const removeToken = () => { const { token } = fcmRequest.message; - token && _removeToken({ gcm: token }); + token && _removeToken(token); }; const response = fetchWithRetry(url, removeToken, { diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index 860900a92471c..80f29459f8430 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -18,6 +18,7 @@ export const _matchToken = Match.OneOf({ apn: String }, { gcm: String }); const PUSH_TITLE_LIMIT = 65; const PUSH_MESSAGE_BODY_LIMIT = 240; +const PUSH_GATEWAY_MAX_RETRIES = 5; type FCMCredentials = { type: string; @@ -78,9 +79,9 @@ export const isFCMCredentials = ajv.compile(FCMCredentialsValida // This type must match the type defined in the push gateway type GatewayNotification = { uniqueId: string; - from: string; - title: string; - text: string; + from?: string; + title?: string; + text?: string; badge?: number; sound?: string; notId?: number; @@ -95,6 +96,7 @@ type GatewayNotification = { sound?: string; notId?: number; category?: string; + expirationSeconds?: number; }; gcm?: { from?: string; @@ -123,8 +125,7 @@ type GatewayNotification = { export type NativeNotificationParameters = { userTokens: string | string[]; notification: PendingPushNotification; - _replaceToken: (currentToken: IPushToken['token'], newToken: IPushToken['token']) => void; - _removeToken: (token: IPushToken['token']) => void; + _removeToken: (token: string) => void; options: RequiredField; }; @@ -167,12 +168,10 @@ class PushClass { } } - private replaceToken(currentToken: IPushToken['token'], newToken: IPushToken['token']): void { - void PushToken.updateMany({ token: currentToken }, { $set: { token: newToken } }); - } - - private removeToken(token: IPushToken['token']): void { - void PushToken.deleteOne({ token }); + private removeToken(token: string): void { + void PushToken.removeOrUnsetByTokenString(token).catch((err) => { + logger.error({ msg: 'Failed to remove push token', err }); + }); } private shouldUseGateway(): boolean { @@ -188,10 +187,13 @@ class PushClass { logger.debug({ msg: 'send to token', token: app.token }); if ('apn' in app.token && app.token.apn) { - countApn.push(app._id); + const userToken = notification.useVoipToken ? app.voipToken : app.token.apn; + const topic = notification.useVoipToken ? `${app.appName}.voip` : app.appName; + // Send to APN - if (this.options.apn) { - sendAPN({ userToken: app.token.apn, notification: { topic: app.appName, ...notification }, _removeToken: this.removeToken }); + if (this.options.apn && userToken) { + countApn.push(app._id); + sendAPN({ userToken, notification: { topic, ...notification }, _removeToken: this.removeToken }); } } else if ('gcm' in app.token && app.token.gcm) { countGcm.push(app._id); @@ -210,7 +212,6 @@ class PushClass { sendFCM({ userTokens: app.token.gcm, notification, - _replaceToken: this.replaceToken, _removeToken: this.removeToken, options: sendGCMOptions as RequiredField, }); @@ -255,7 +256,7 @@ class PushClass { service: 'apn' | 'gcm', token: string, notification: Optional, - tries = 0, + retryOptions: { tries: number; maxRetries: number } = { tries: 0, maxRetries: PUSH_GATEWAY_MAX_RETRIES }, ): Promise { notification.uniqueId = this.options.uniqueId; @@ -275,16 +276,7 @@ class PushClass { if (result.status === 406) { logger.info({ msg: 'removing push token', token }); - await PushToken.deleteMany({ - $or: [ - { - 'token.apn': token, - }, - { - 'token.gcm': token, - }, - ], - }); + this.removeToken(token); return; } @@ -302,22 +294,24 @@ class PushClass { return; } + const { tries, maxRetries } = retryOptions; + logger.error({ msg: 'Error sending push to gateway', tries, err: response }); - if (tries <= 4) { + if (tries < maxRetries) { // [1, 2, 4, 8, 16] minutes (total 31) const ms = 60000 * Math.pow(2, tries); logger.log({ msg: 'Retrying push to gateway', tries: tries + 1, in: ms }); - setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, tries + 1), ms); + setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, { tries: tries + 1, maxRetries }), ms); } } private getGatewayNotificationData(notification: PendingPushNotification): Omit { - // Gateway currently accepts every attribute from the PendingPushNotification type, except for the priority + // Gateway currently accepts every attribute from the PendingPushNotification type, except for the priority and useVoipToken // If new attributes are added to the PendingPushNotification type, they'll need to be removed here as well. - const { priority: _priority, ...notifData } = notification; + const { priority: _priority, useVoipToken: _useVoipToken, ...notifData } = notification; return { ...notifData, @@ -335,35 +329,47 @@ class PushClass { } const gatewayNotification = this.getGatewayNotificationData(notification); + const retryOptions = { + tries: 0, + maxRetries: notification.useVoipToken ? 0 : PUSH_GATEWAY_MAX_RETRIES, + }; for (const gateway of this.options.gateways) { logger.debug({ msg: 'send to token', token: app.token }); if ('apn' in app.token && app.token.apn) { - countApn.push(app._id); - return this.sendGatewayPush(gateway, 'apn', app.token.apn, { topic: app.appName, ...gatewayNotification }); + const token = notification.useVoipToken ? app.voipToken : app.token.apn; + const topic = notification.useVoipToken ? `${app.appName}.voip` : app.appName; + + if (token) { + countApn.push(app._id); + return this.sendGatewayPush(gateway, 'apn', token, { topic, ...gatewayNotification }, retryOptions); + } } if ('gcm' in app.token && app.token.gcm) { countGcm.push(app._id); - return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, gatewayNotification); + return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, gatewayNotification, retryOptions); } } } - private async sendNotification(notification: PendingPushNotification): Promise<{ apn: string[]; gcm: string[] }> { + private async sendNotification( + notification: PendingPushNotification, + options: { skipTokenId?: IPushToken['_id'] } = {}, + ): Promise<{ apn: string[]; gcm: string[] }> { logger.debug({ msg: 'Sending notification', notification }); const countApn: string[] = []; const countGcm: string[] = []; - if (notification.from !== String(notification.from)) { + if (notification.from && notification.from !== String(notification.from)) { throw new Error('Push.send: option "from" not a string'); } - if (notification.title !== String(notification.title)) { + if (notification.title && notification.title !== String(notification.title)) { throw new Error('Push.send: option "title" not a string'); } - if (notification.text !== String(notification.text)) { + if (notification.text && notification.text !== String(notification.text)) { throw new Error('Push.send: option "text" not a string'); } @@ -373,12 +379,9 @@ class PushClass { userId: notification.userId, }); - const query = { - userId: notification.userId, - $or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }], - }; - - const appTokens = PushToken.find(query); + const appTokens = options.skipTokenId + ? PushToken.findTokensByUserIdExceptId(notification.userId, options.skipTokenId) + : PushToken.findAllTokensByUserId(notification.userId); for await (const app of appTokens) { logger.debug({ msg: 'send to token', token: app.token }); @@ -427,9 +430,9 @@ class PushClass { private _validateDocument(notification: PendingPushNotification): void { // Check the general notification check(notification, { - from: String, - title: String, - text: String, + from: Match.Optional(String), + title: Match.Optional(String), + text: Match.Optional(String), sent: Match.Optional(Boolean), sending: Match.Optional(Match.Integer), badge: Match.Optional(Match.Integer), @@ -438,6 +441,7 @@ class PushClass { contentAvailable: Match.Optional(Match.Integer), apn: Match.Optional({ category: Match.Optional(String), + expirationSeconds: Match.Optional(Match.Integer), }), gcm: Match.Optional({ image: Match.Optional(String), @@ -448,6 +452,7 @@ class PushClass { createdAt: Date, createdBy: Match.OneOf(String, null), priority: Match.Optional(Match.Integer), + useVoipToken: Match.Optional(Boolean), }); if (!notification.userId) { @@ -470,15 +475,15 @@ class PushClass { createdBy: '', sent: false, sending: 0, - title: truncateString(options.title, PUSH_TITLE_LIMIT), - text: truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT), + ...(options.title && { title: truncateString(options.title, PUSH_TITLE_LIMIT) }), + ...(options.text && { text: truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT) }), - ...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority'), + ...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority', 'useVoipToken'), ...(this.hasApnOptions(options) ? { apn: { - ...pick(options.apn, 'category'), + ...pick(options.apn, 'category', 'expirationSeconds'), }, } : {}), @@ -495,7 +500,7 @@ class PushClass { this._validateDocument(notification); try { - await this.sendNotification(notification); + await this.sendNotification(notification, pick(options, 'skipTokenId')); } catch (error: any) { logger.debug({ msg: 'Could not send notification to user', diff --git a/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx index 4b3ed1b4ae99d..3384a260e5cb2 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx @@ -38,7 +38,9 @@ const PreferencesNotificationsSection = () => { const loginEmailEnabled = useSetting('Device_Management_Enable_Login_Emails'); const allowLoginEmailPreference = useSetting('Device_Management_Allow_Login_Email_preference'); const showNewLoginEmailPreference = loginEmailEnabled && allowLoginEmailPreference; - const showMobileRinging = useSetting('VideoConf_Mobile_Ringing'); + const showVideoConfMobileRinging = useSetting('VideoConf_Mobile_Ringing'); + const showVoipMobileRinging = useSetting('VoIP_TeamCollab_Mobile_Ringing_Enabled'); + const showMobileRinging = showVideoConfMobileRinging || showVoipMobileRinging; const notify = useNotification(); const userEmailNotificationMode = useUserPreference('emailNotificationMode') as keyof typeof emailNotificationOptionsLabelMap; diff --git a/apps/meteor/ee/server/settings/voip.ts b/apps/meteor/ee/server/settings/voip.ts index c04700948299c..5468145e6c67c 100644 --- a/apps/meteor/ee/server/settings/voip.ts +++ b/apps/meteor/ee/server/settings/voip.ts @@ -17,6 +17,14 @@ export function addSettings(): Promise { i18nDescription: 'VoIP_TeamCollab_Screen_Sharing_Enabled_Description', }); + await this.add('VoIP_TeamCollab_Mobile_Ringing_Enabled', false, { + type: 'boolean', + public: true, + invalidValue: false, + alert: 'VoIP_TeamCollab_Mobile_Ringing_Enabled_Alert', + i18nDescription: 'VoIP_TeamCollab_Mobile_Ringing_Enabled_Description', + }); + await this.add('VoIP_TeamCollab_Ice_Servers', 'stun:stun.l.google.com:19302', { type: 'string', public: true, diff --git a/apps/meteor/server/services/media-call/logger.ts b/apps/meteor/server/services/media-call/logger.ts new file mode 100644 index 0000000000000..a021409284141 --- /dev/null +++ b/apps/meteor/server/services/media-call/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('MediaCall'); diff --git a/apps/meteor/server/services/media-call/push/getPushNotificationType.ts b/apps/meteor/server/services/media-call/push/getPushNotificationType.ts new file mode 100644 index 0000000000000..8683eaf705459 --- /dev/null +++ b/apps/meteor/server/services/media-call/push/getPushNotificationType.ts @@ -0,0 +1,22 @@ +import type { IMediaCall } from '@rocket.chat/core-typings'; +import type { VoipPushNotificationType } from '@rocket.chat/media-calls'; + +export function getPushNotificationType(call: IMediaCall): VoipPushNotificationType { + if (call.acceptedAt) { + return 'answeredElsewhere'; + } + + if (call.endedBy?.id === call.callee.id || call.hangupReason === 'rejected') { + return 'declinedElsewhere'; + } + + if (call.endedBy?.id === call.caller.id) { + return 'remoteEnded'; + } + + if (call.ended) { + return 'unanswered'; + } + + return 'incoming_call'; +} diff --git a/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts new file mode 100644 index 0000000000000..7c0b2b9d0d4fd --- /dev/null +++ b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts @@ -0,0 +1,127 @@ +import type { IMediaCall, IUser, MediaCallContact, MediaCallActorType } from '@rocket.chat/core-typings'; +import type { VoipPushNotificationEventType } from '@rocket.chat/media-calls'; +import { MediaCalls, Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { getPushNotificationType } from './getPushNotificationType'; +import { metrics } from '../../../../app/metrics/server/lib/metrics'; +import { Push } from '../../../../app/push/server/push'; +import PushNotification from '../../../../app/push-notifications/server/lib/PushNotification'; +import { settings } from '../../../../app/settings/server'; +import { getUserAvatarURL } from '../../../../app/utils/server/getUserAvatarURL'; +import { getUserPreference } from '../../../../app/utils/server/lib/getUserPreference'; +import { logger } from '../logger'; + +async function getActorUser>( + actor: MediaCallContact, +): Promise { + const options = { projection: { name: 1, username: 1, freeSwitchExtension: 1 } }; + + switch (actor.type) { + case 'user': + return Users.findOneById(actor.id, options); + case 'sip': + return Users.findOneByFreeSwitchExtension(actor.id, options); + } +} + +async function getActorUserData( + actor: MediaCallContact, +): Promise<{ type: MediaCallActorType; id: string; name: string; avatarUrl?: string; username?: string }> { + const actorUsername = actor.type === 'user' ? actor.username : undefined; + const actorExtension = actor.sipExtension || (actor.type === 'sip' ? actor.id : undefined); + + const data = { + type: actor.type, + id: actor.id, + name: actor.displayName || actorUsername || actorExtension || '', + } as const; + + const user = await getActorUser(actor); + + if (user) { + const username = user.username || actorUsername; + + return { + ...data, + name: user.name || user.username || user.freeSwitchExtension || data.name, + ...(username && { username, avatarUrl: getUserAvatarURL(username) }), + }; + } + + return { + ...data, + ...(actorUsername && { username: actorUsername, avatarUrl: getUserAvatarURL(actorUsername) }), + }; +} + +async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: VoipPushNotificationEventType): Promise { + if (settings.get('Push_enable') !== true || settings.get('VoIP_TeamCollab_Mobile_Ringing_Enabled') !== true) { + return; + } + + const call = await MediaCalls.findOneById(callId); + if (!call) { + logger.error({ msg: 'Failed to send push notification: Media Call not found', callId }); + return; + } + + if (call.callee.type !== 'user') { + logger.error({ msg: 'Failed to send push notification: Invalid Callee Type', callId }); + return; + } + + const { + kind, + callee: { id: userId }, + } = call; + + if (!(await getUserPreference(userId, 'enableMobileRinging'))) { + return; + } + + // If the call was accepted, we don't need to notify when it ends + if (call.acceptedAt && event !== 'answer') { + return; + } + + const type = getPushNotificationType(call); + // If the state changed before we had a chance to send the incoming call, skip it altogether + if (event === 'new' && type !== 'incoming_call') { + return; + } + if (type === 'incoming_call' && event !== 'new') { + return; + } + + const caller = await getActorUserData(call.caller); + + metrics.notificationsSent.inc({ notification_type: 'mobile' }); + const useVoipToken = type === 'incoming_call'; + + await Push.send({ + useVoipToken, + priority: 10, + payload: { + host: Meteor.absoluteUrl(), + hostName: settings.get('Site_Name'), + notificationType: 'voip', + type, + kind, + callId: call._id, + caller, + createdAt: call.createdAt.toISOString(), + }, + ...(useVoipToken && { apn: { expirationSeconds: 60 } }), + userId, + notId: PushNotification.getNotificationId(call._id), + // We should not send state change notifications to the device where the call was accepted/rejected + ...(call.callee.contractId && { skipTokenId: call.callee.contractId }), + }); +} + +export function sendVoipPushNotification(callId: IMediaCall['_id'], event: VoipPushNotificationEventType): void { + void sendVoipPushNotificationAsync(callId, event).catch((err) => { + logger.error({ msg: 'Failed to send VoIP push notification', err, callId, event }); + }); +} diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 31cf7a4b69897..6542ce7d12097 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -7,7 +7,6 @@ import type { CallHistoryItemState, IExternalMediaCallHistoryItem, } from '@rocket.chat/core-typings'; -import { Logger } from '@rocket.chat/logger'; import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall } from '@rocket.chat/media-calls'; import type { CallFeature, @@ -21,13 +20,13 @@ import type { InsertionModel } from '@rocket.chat/model-typings'; import { CallHistory, MediaCalls, Rooms, Users } from '@rocket.chat/models'; import { callStateToTranslationKey, getHistoryMessagePayload } from '@rocket.chat/ui-voip/dist/ui-kit/getHistoryMessagePayload'; +import { logger } from './logger'; +import { sendVoipPushNotification } from './push/sendVoipPushNotification'; import { sendMessage } from '../../../app/lib/server/functions/sendMessage'; import { settings } from '../../../app/settings/server'; import { i18n } from '../../lib/i18n'; import { createDirectMessage } from '../../methods/createDirectMessage'; -const logger = new Logger('media-call service'); - export class MediaCallService extends ServiceClassInternal implements IMediaCallService { protected name = 'media-call'; @@ -36,6 +35,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall callServer.emitter.on('signalRequest', ({ toUid, signal }) => this.sendSignal(toUid, signal)); callServer.emitter.on('callUpdated', (params) => api.broadcast('media-call.updated', params)); callServer.emitter.on('historyUpdate', ({ callId }) => setImmediate(() => this.saveCallToHistory(callId))); + callServer.emitter.on('pushNotificationRequest', ({ callId, event }) => sendVoipPushNotification(callId, event)); this.onEvent('media-call.updated', (params) => callServer.receiveCallUpdate(params)); this.onEvent('watch.settings', async ({ setting }): Promise => { @@ -374,6 +374,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall private getMediaServerSettings(): IMediaCallServerSettings { const sipEnabled = settings.get('VoIP_TeamCollab_SIP_Integration_Enabled') ?? false; + const mobileRinging = settings.get('VoIP_TeamCollab_Mobile_Ringing_Enabled') ?? false; const forceSip = sipEnabled && (settings.get('VoIP_TeamCollab_SIP_Integration_For_Internal_Calls') ?? false); return { @@ -393,6 +394,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall port: settings.get('VoIP_TeamCollab_SIP_Server_Port') ?? 5060, }, }, + mobileRinging, permissionCheck: (uid, callType) => this.userHasMediaCallPermission(uid, callType), isFeatureAvailableForUser: (uid, feature) => this.userHasFeaturePermission(uid, feature), }; diff --git a/ee/packages/media-calls/src/definition/IMediaCallServer.ts b/ee/packages/media-calls/src/definition/IMediaCallServer.ts index 36ebdc836d0f3..8449819ddb08b 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallServer.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallServer.ts @@ -4,10 +4,14 @@ import type { CallFeature, ClientMediaSignal, ClientMediaSignalBody, ServerMedia import type { InternalCallParams, SignalProcessingOptions } from './common'; +export type VoipPushNotificationType = 'incoming_call' | 'remoteEnded' | 'answeredElsewhere' | 'declinedElsewhere' | 'unanswered'; +export type VoipPushNotificationEventType = 'new' | 'answer' | 'end'; + export type MediaCallServerEvents = { callUpdated: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }; signalRequest: { toUid: IUser['_id']; signal: ServerMediaSignal }; historyUpdate: { callId: string }; + pushNotificationRequest: { callId: string; event: VoipPushNotificationEventType }; }; export interface IMediaCallServerSettings { @@ -29,6 +33,8 @@ export interface IMediaCallServerSettings { }; }; + mobileRinging: boolean; + permissionCheck: (uid: IUser['_id'], callType: 'internal' | 'external' | 'any') => Promise; isFeatureAvailableForUser: (uid: IUser['_id'], feature: CallFeature) => boolean; } @@ -40,6 +46,7 @@ export interface IMediaCallServer { sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void; reportCallUpdate(params: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }): void; updateCallHistory(params: { callId: string }): void; + sendPushNotification(params: { callId: string; event: VoipPushNotificationEventType }): void; // functions that are run on events receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal, options?: SignalProcessingOptions): Promise; diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index d247f7236a1a3..7ea06dbcc64b7 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -169,6 +169,17 @@ export class GlobalSignalProcessor { await mediaCallDirector.renewCallId(call._id); } + if (call.state !== 'active') { + const otherActor = role === 'caller' ? call.callee : call.caller; + if (otherActor.type === 'user') { + this.sendSignal(otherActor.id, { + callId: call._id, + type: 'notification', + notification: 'trying', + }); + } + } + if (!signal.requestSignals) { return; } diff --git a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index 1ed4d1566b7c9..82156e39fa623 100644 --- a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -5,6 +5,7 @@ import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import { UserActorSignalProcessor } from './CallSignalProcessor'; import { BaseMediaCallAgent } from '../../base/BaseAgent'; +import type { VoipPushNotificationEventType } from '../../definition/IMediaCallServer'; import type { SignalProcessingOptions } from '../../definition/common'; import { logger } from '../../logger'; import { getMediaCallServer } from '../../server/injection'; @@ -36,6 +37,8 @@ export class UserActorAgent extends BaseMediaCallAgent { return; } + this.sendPushNotification({ callId: call._id, event: 'answer' }); + const initialOfferSignal = await getInitialOfferSignal(call, this.role); if (!initialOfferSignal) { logger.debug('The call was accepted but the webrtc offer is not yet available.'); @@ -45,6 +48,10 @@ export class UserActorAgent extends BaseMediaCallAgent { } public async onCallEnded(callId: string): Promise { + if (this.role === 'callee') { + this.sendPushNotification({ callId, event: 'end' }); + } + return this.sendSignal({ callId, type: 'notification', @@ -67,6 +74,9 @@ export class UserActorAgent extends BaseMediaCallAgent { } await this.sendSignal(getNewCallSignal(call, this.role)); + if (this.role === 'callee') { + this.sendPushNotification({ callId: call._id, event: 'new' }); + } } public async onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise { @@ -154,4 +164,8 @@ export class UserActorAgent extends BaseMediaCallAgent { logger.debug({ msg: 'UserActorAgent.onDTMF', callId, dtmf, duration, role: this.role }); // internal calls have nothing to do with DTMFs } + + private sendPushNotification(params: { callId: string; event: VoipPushNotificationEventType }): void { + getMediaCallServer().sendPushNotification(params); + } } diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index 2fbe07d8e0ab2..b7fa20bf1225a 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'crypto'; + import type { IMediaCall, IMediaCallNegotiation, @@ -206,8 +208,9 @@ class MediaCallDirector { calleeAgent.oppositeAgent = callerAgent; const allowedFeatures = features.filter((feature) => getMediaCallServer().isFeatureAvailableForUser(caller.id, feature)); - - const call: Omit = { + const call: Omit = { + // Use UUIDs to identify all media calls, for better compatibility with libs that require it (such as React Native's CallKit) + _id: randomUUID(), service, kind: 'direct', state: 'none', diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index 6851762e59c9f..86aaa932db858 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -11,7 +11,12 @@ import type { import { mediaCallDirector } from './CallDirector'; import { getDefaultSettings } from './getDefaultSettings'; import { stripSensitiveDataFromSignal } from './stripSensitiveData'; -import type { IMediaCallServer, IMediaCallServerSettings, MediaCallServerEvents } from '../definition/IMediaCallServer'; +import type { + IMediaCallServer, + IMediaCallServerSettings, + MediaCallServerEvents, + VoipPushNotificationEventType, +} from '../definition/IMediaCallServer'; import { CallRejectedError } from '../definition/common'; import type { SignalProcessingOptions, GetActorContactOptions, InternalCallParams } from '../definition/common'; import { InternalCallProvider } from '../internal/InternalCallProvider'; @@ -69,6 +74,15 @@ export class MediaCallServer implements IMediaCallServer { this.emitter.emit('historyUpdate', params); } + public sendPushNotification(params: { callId: string; event: VoipPushNotificationEventType }): void { + if (!this.settings.mobileRinging) { + return; + } + logger.debug({ msg: 'MediaCallServer.sendPushNotification', params }); + + this.emitter.emit('pushNotificationRequest', params); + } + public async requestCall(params: InternalCallParams): Promise { try { const fullParams = await this.parseCallContacts(params); diff --git a/ee/packages/media-calls/src/server/getDefaultSettings.ts b/ee/packages/media-calls/src/server/getDefaultSettings.ts index 4445d13643d78..07ddfe33ccc9a 100644 --- a/ee/packages/media-calls/src/server/getDefaultSettings.ts +++ b/ee/packages/media-calls/src/server/getDefaultSettings.ts @@ -18,6 +18,7 @@ export function getDefaultSettings(): IMediaCallServerSettings { port: 5080, }, }, + mobileRinging: false, permissionCheck: async () => false, isFeatureAvailableForUser: () => false, diff --git a/packages/core-typings/src/IPushNotificationConfig.ts b/packages/core-typings/src/IPushNotificationConfig.ts index 3279d42aa7b2c..2695294408139 100644 --- a/packages/core-typings/src/IPushNotificationConfig.ts +++ b/packages/core-typings/src/IPushNotificationConfig.ts @@ -1,10 +1,12 @@ +import type { IPushToken } from './IPushToken'; + export interface IPushNotificationConfig { - from: string; + from?: string; badge?: number; sound?: string; priority?: number; - title: string; - text: string; + title?: string; + text?: string; payload?: Record; userId: string; notId?: number; @@ -13,7 +15,9 @@ export interface IPushNotificationConfig { image: string; }; apn?: { - category: string; - topicSuffix?: string; + category?: string; + expirationSeconds?: number; }; + useVoipToken?: boolean; + skipTokenId?: IPushToken['_id']; } diff --git a/packages/core-typings/src/IPushToken.ts b/packages/core-typings/src/IPushToken.ts index 0113241e249e4..bfface372e03a 100644 --- a/packages/core-typings/src/IPushToken.ts +++ b/packages/core-typings/src/IPushToken.ts @@ -1,7 +1,9 @@ import type { IRocketChatRecord } from './IRocketChatRecord'; import type { ILoginToken } from './IUser'; -export type IPushTokenTypes = 'gcm' | 'apn'; +export const pushTokenTypes = ['gcm', 'apn'] as const; + +export type IPushTokenTypes = (typeof pushTokenTypes)[number]; export interface IPushToken extends IRocketChatRecord { token: Partial>; diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 011df49c0f28c..35ea69ad055eb 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -27,7 +27,7 @@ export type * from './ICustomSound'; export type * from './ICloud'; export * from './IServerEvent'; export type * from './IRocketChatAssets'; -export type * from './IPushToken'; +export * from './IPushToken'; export type * from './IPushNotificationConfig'; export type * from './SlashCommands'; export * from './license'; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2bd2ef52383be..f1c31f59ba8fe 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -5809,6 +5809,9 @@ "VoIP_TeamCollab_SIP_Integration": "SIP Integration", "VoIP_TeamCollab_SIP_Integration_Enabled": "SIP Integration Enabled", "VoIP_TeamCollab_SIP_Integration_For_Internal_Calls": "Route internal calls through the SIP integration", + "VoIP_TeamCollab_Mobile_Ringing_Enabled": "Mobile Ringing", + "VoIP_TeamCollab_Mobile_Ringing_Enabled_Alert": "This feature is currently in an experimental stage and may not yet be fully supported by the mobile app. When enabled it will send additional Push Notifications to users.", + "VoIP_TeamCollab_Mobile_Ringing_Enabled_Description": "Allow users to make and receive calls on the mobile app.", "VoIP_TeamCollab_Screen_Sharing_Enabled": "Screen sharing", "VoIP_TeamCollab_Screen_Sharing_Enabled_Description": "Allow users to share their screen during voice calls.", "VoIP_TeamCollab_Screen_Sharing_Enabled_Alert": "Screen sharing is currently in beta.", diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index e1e3506c50888..32fb06a6a7882 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -54,7 +54,8 @@ export type CallAnswer = (typeof callAnswerList)[number]; export type CallNotification = | 'accepted' // notify that the call has been accepted by both actors | 'active' // notify that call activity was confirmed - | 'hangup'; // notify that the call is over; + | 'hangup' // notify that the call is over; + | 'trying'; // notify that the other client is connecting but still need more time export type CallRejectedReason = | 'invalid-call-id' // the call id can't be used for a new call diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index b081ea3cd6cfc..184a3675b19a4 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -45,7 +45,7 @@ export interface IClientMediaCallConfig { supportedFeatures: CallFeature[]; } -const TIMEOUT_TO_ACCEPT = 30000; +const TIMEOUT_TO_ACCEPT = 60000; const TIMEOUT_TO_CONFIRM_ACCEPTANCE = 2000; const TIMEOUT_TO_PROGRESS_SIGNALING = 10000; const STATE_REPORT_DELAY = 300; @@ -54,9 +54,10 @@ const CALLS_WITH_NO_REMOTE_DATA_REPORT_DELAY = 5000; // if the server tells us we're the caller in a call we don't recognize, ignore it completely const AUTO_IGNORE_UNKNOWN_OUTBOUND_CALLS = true; -type StateTimeoutHandler = { +type StateTimeoutData = { state: ClientState; - handler: ReturnType; + reset: () => void; + clear: () => void; }; export class ClientMediaCall implements IClientMediaCall { @@ -178,6 +179,8 @@ export class ClientMediaCall implements IClientMediaCall { private acceptedLocally: boolean; + private acceptedRemotely: boolean; + private endedLocally: boolean; private hasRemoteData: boolean; @@ -188,7 +191,7 @@ export class ClientMediaCall implements IClientMediaCall { private earlySignals: Set; - private stateTimeoutHandlers: Set; + private stateTimeoutHandlers: Set; private remoteCallId: string | null; @@ -290,6 +293,7 @@ export class ClientMediaCall implements IClientMediaCall { this.remoteCallId = null; this.acceptedLocally = false; + this.acceptedRemotely = false; this.endedLocally = false; this.hasRemoteData = false; this.initialized = false; @@ -657,6 +661,12 @@ export class ClientMediaCall implements IClientMediaCall { } this.acceptedLocally = true; + // If the server already signed us into this call, go straight to the accepted state + if (this.acceptedRemotely) { + this.changeState('accepted'); + return; + } + this.config.transporter.answer(this.callId, 'accept', { supportedFeatures: this.config.supportedFeatures }); if (this.getClientState() === 'accepting') { @@ -1156,6 +1166,9 @@ export class ClientMediaCall implements IClientMediaCall { this.changeState('active'); } return; + case 'trying': + this.resetStateTimeouts(); + break; case 'hangup': return this.flagAsEnded('remote'); @@ -1163,7 +1176,12 @@ export class ClientMediaCall implements IClientMediaCall { } private async flagAsAccepted(enabledFeatures?: CallFeature[]): Promise { + if (!this.isPendingAcceptance()) { + return; + } + this.config.logger?.debug('ClientMediaCall.flagAsAccepted'); + this.acceptedRemotely = true; if (enabledFeatures && this._state !== 'accepted') { this.enabledFeatures = enabledFeatures; @@ -1175,16 +1193,15 @@ export class ClientMediaCall implements IClientMediaCall { return; } - if (!this.acceptedLocally) { - this.config.transporter.sendError(this.callId, { errorType: 'signaling', errorCode: 'not-accepted', critical: true }); - this.config.logger?.error('Trying to activate a call that was not yet accepted locally.'); - return; - } - if (this.contractState === 'proposed') { this.contractState = 'self-signed'; } + if (!this.acceptedLocally) { + this.config.logger?.debug('Server signed us into a call that we have not yet accepted locally.'); + return; + } + // Both sides of the call have accepted it, we can change the state now this.changeState('accepted'); } @@ -1212,26 +1229,39 @@ export class ClientMediaCall implements IClientMediaCall { return; } - const handler = { + let handler: ReturnType | null = null; + + const data = { state, - handler: setTimeout(() => { - if (this.stateTimeoutHandlers.has(handler)) { - this.stateTimeoutHandlers.delete(handler); + clear: () => { + if (handler) { + clearTimeout(handler); } + handler = null; + }, + reset: () => { + data.clear(); + handler = setTimeout(() => { + if (this.stateTimeoutHandlers.has(data)) { + this.stateTimeoutHandlers.delete(data); + } - if (state !== this.getClientState()) { - return; - } + if (state !== this.getClientState()) { + return; + } - if (callback) { - callback(); - } else { - void this.hangup(this.getTimeoutHangupReason(state)); - } - }, timeout), + if (callback) { + callback(); + } else { + void this.hangup(this.getTimeoutHangupReason(state)); + } + }, timeout); + }, }; - this.stateTimeoutHandlers.add(handler); + data.reset(); + + this.stateTimeoutHandlers.add(data); } private getTimeoutHangupReason(state: ClientState): CallHangupReason { @@ -1250,6 +1280,19 @@ export class ClientMediaCall implements IClientMediaCall { return 'timeout'; } + private resetStateTimeouts(): void { + this.config.logger?.debug('ClientMediaCall.resetStateTimeouts'); + const clientState = this.getClientState(); + + for (const handler of this.stateTimeoutHandlers.values()) { + if (handler.state !== clientState) { + continue; + } + + handler.reset(); + } + } + private updateStateTimeouts(): void { this.config.logger?.debug('ClientMediaCall.updateStateTimeouts'); const clientState = this.getClientState(); @@ -1259,14 +1302,14 @@ export class ClientMediaCall implements IClientMediaCall { continue; } - clearTimeout(handler.handler); + handler.clear(); this.stateTimeoutHandlers.delete(handler); } } private clearStateTimeouts(): void { for (const handler of this.stateTimeoutHandlers.values()) { - clearTimeout(handler.handler); + handler.clear(); } this.stateTimeoutHandlers.clear(); } diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index ec0804a8fa118..64dd3d4592786 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -72,6 +72,8 @@ export class MediaSignalingSession extends Emitter { private lastState: { hasCall: boolean; hasVisibleCall: boolean; hasBusyCall: boolean }; + private sessionEnded = false; + private registration: SessionRegistration; public get sessionId(): string { @@ -130,6 +132,7 @@ export class MediaSignalingSession extends Emitter { } public endSession(): void { + this.sessionEnded = true; this.registration.endSession(); this.disableStateReport(); @@ -193,6 +196,9 @@ export class MediaSignalingSession extends Emitter { } public async processSignal(signal: ServerMediaSignal): Promise { + if (this.sessionEnded) { + return; + } this.config.logger?.debug('MediaSignalingSession.processSignal', signal); if ('callId' in signal) { return this.processCallSignal(signal); diff --git a/packages/model-typings/src/models/IPushTokenModel.ts b/packages/model-typings/src/models/IPushTokenModel.ts index 68345f281c9a6..4b907ce47ee24 100644 --- a/packages/model-typings/src/models/IPushTokenModel.ts +++ b/packages/model-typings/src/models/IPushTokenModel.ts @@ -1,5 +1,5 @@ import type { AtLeast, IPushToken, IUser } from '@rocket.chat/core-typings'; -import type { DeleteResult, FindOptions, InsertOneResult, UpdateResult } from 'mongodb'; +import type { DeleteResult, FindOptions, InsertOneResult, UpdateResult, FindCursor } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -9,6 +9,12 @@ export interface IPushTokenModel extends IBaseModel { countApnTokens(): Promise; findOneByTokenAndAppName(token: IPushToken['token'], appName: IPushToken['appName']): Promise; findFirstByUserId(userId: IUser['_id'], options?: FindOptions): Promise; + findAllTokensByUserId(userId: IUser['_id'], options?: FindOptions): FindCursor; + findTokensByUserIdExceptId( + userId: IUser['_id'], + idToIgnore: IPushToken['_id'], + options?: FindOptions, + ): FindCursor; insertToken(data: AtLeast): Promise>; refreshTokenById( @@ -21,4 +27,5 @@ export interface IPushTokenModel extends IBaseModel { removeAllByUserId(userId: string): Promise; removeAllByTokenStringAndUserId(token: string, userId: string): Promise; + removeOrUnsetByTokenString(token: string): Promise; } diff --git a/packages/models/src/models/PushToken.ts b/packages/models/src/models/PushToken.ts index a0ccbf62c214e..22c11f111855f 100644 --- a/packages/models/src/models/PushToken.ts +++ b/packages/models/src/models/PushToken.ts @@ -1,6 +1,6 @@ import type { IPushToken, IUser, AtLeast } from '@rocket.chat/core-typings'; import type { IPushTokenModel } from '@rocket.chat/model-typings'; -import type { Db, DeleteResult, FindOptions, IndexDescription, InsertOneResult, UpdateResult } from 'mongodb'; +import type { Db, DeleteResult, FindOptions, IndexDescription, InsertOneResult, UpdateResult, FindCursor } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -46,6 +46,31 @@ export class PushTokenRaw extends BaseRaw implements IPushTokenModel return this.findOne({ userId }, options); } + findAllTokensByUserId(userId: IUser['_id'], options?: FindOptions): FindCursor { + return this.find( + { + userId, + $or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }], + }, + options, + ); + } + + findTokensByUserIdExceptId( + userId: IUser['_id'], + idToIgnore: IPushToken['_id'], + options?: FindOptions, + ): FindCursor { + return this.find( + { + _id: { $ne: idToIgnore }, + userId, + $or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }], + }, + options, + ); + } + async insertToken(data: AtLeast): Promise> { return this.insertOne({ enabled: true, @@ -117,8 +142,35 @@ export class PushTokenRaw extends BaseRaw implements IPushTokenModel { 'token.gcm': token, }, + { + voipToken: token, + }, ], userId, }); } + + async removeOrUnsetByTokenString(token: string): Promise { + await this.deleteMany({ + $or: [ + { + 'token.apn': token, + }, + { + 'token.gcm': token, + }, + ], + }); + + await this.updateMany( + { + voipToken: token, + }, + { + $unset: { + voipToken: 1, + }, + }, + ); + } }