diff --git a/.changeset/eighty-experts-sort.md b/.changeset/eighty-experts-sort.md new file mode 100644 index 0000000000000..b71f15e16562d --- /dev/null +++ b/.changeset/eighty-experts-sort.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/ui-voip': patch +'@rocket.chat/meteor': patch +--- + +Fixes empty notifications sent when a voice call ends diff --git a/.changeset/fair-lions-smell.md b/.changeset/fair-lions-smell.md new file mode 100644 index 0000000000000..403b673842ee0 --- /dev/null +++ b/.changeset/fair-lions-smell.md @@ -0,0 +1,10 @@ +--- +'@rocket.chat/media-calls': minor +'@rocket.chat/core-services': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/models': minor +'@rocket.chat/meteor': minor +'@rocket.chat/media-signaling': minor +--- + +Adds a new REST endpoint to accept or reject media calls without an active media session diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index 176141af83e08..5a6a6f06cbbab 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -21,6 +21,7 @@ import './v1/integrations'; import './v1/invites'; import './v1/import'; import './v1/ldap'; +import './v1/media-calls'; import './v1/misc'; import './v1/permissions'; import './v1/presence'; diff --git a/apps/meteor/app/api/server/v1/media-calls.ts b/apps/meteor/app/api/server/v1/media-calls.ts new file mode 100644 index 0000000000000..9d6e0bde77d84 --- /dev/null +++ b/apps/meteor/app/api/server/v1/media-calls.ts @@ -0,0 +1,98 @@ +import { MediaCall } from '@rocket.chat/core-services'; +import type { IMediaCall } from '@rocket.chat/core-typings'; +import type { CallAnswer, CallFeature } from '@rocket.chat/media-signaling'; +import { callFeatureList, callAnswerList } from '@rocket.chat/media-signaling'; +import { + ajv, + validateNotFoundErrorResponse, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; +import type { JSONSchemaType } from 'ajv'; + +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; + +type MediaCallsAnswer = { + callId: string; + contractId: string; + + answer: CallAnswer; + + supportedFeatures?: CallFeature[]; +}; + +const MediaCallsAnswerSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + }, + contractId: { + type: 'string', + }, + answer: { + type: 'string', + enum: callAnswerList, + }, + supportedFeatures: { + type: 'array', + items: { + type: 'string', + enum: callFeatureList, + }, + nullable: true, + }, + }, + required: ['callId', 'contractId', 'answer'], + additionalProperties: false, +}; + +export const isMediaCallsAnswerProps = ajv.compile(MediaCallsAnswerSchema); + +const mediaCallsAnswerEndpoints = API.v1.post( + 'media-calls.answer', + { + response: { + 200: ajv.compile<{ + call: IMediaCall; + }>({ + additionalProperties: false, + type: 'object', + properties: { + call: { + type: 'object', + $ref: '#/components/schemas/IMediaCall', + description: 'The updated call information.', + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['call', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + body: isMediaCallsAnswerProps, + authRequired: true, + }, + async function action() { + const call = await MediaCall.answerCall(this.userId, this.bodyParams); + + return API.v1.success({ + call, + }); + }, +); + +type MediaCallsAnswerEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends MediaCallsAnswerEndpoints {} +} 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/lib/server/functions/sendMessage.ts b/apps/meteor/app/lib/server/functions/sendMessage.ts index 5500b1bdf9453..73fa409999182 100644 --- a/apps/meteor/app/lib/server/functions/sendMessage.ts +++ b/apps/meteor/app/lib/server/functions/sendMessage.ts @@ -13,9 +13,10 @@ import { afterSaveMessage } from '../lib/afterSaveMessage'; import { notifyOnRoomChangedById } from '../lib/notifyListener'; import { validateCustomMessageFields } from '../lib/validateCustomMessageFields'; -type SendMessageOptions = { +export type SendMessageOptions = { upsert?: boolean; previewUrls?: string[]; + skipNotifications?: boolean; }; // TODO: most of the types here are wrong, but I don't want to change them now @@ -289,7 +290,7 @@ export const sendMessage = async function (user: any, message: any, room: any, o void Apps.self?.triggerEvent(messageEvent, message); } - await afterSaveMessage(message, room, user); + await afterSaveMessage(message, room, user, { options }); void notifyOnRoomChangedById(message.rid); diff --git a/apps/meteor/app/lib/server/lib/afterSaveMessage.ts b/apps/meteor/app/lib/server/lib/afterSaveMessage.ts index 3ab96e1ab7478..d1cf0e15927bf 100644 --- a/apps/meteor/app/lib/server/lib/afterSaveMessage.ts +++ b/apps/meteor/app/lib/server/lib/afterSaveMessage.ts @@ -4,10 +4,28 @@ import type { Updater } from '@rocket.chat/models'; import { Rooms } from '@rocket.chat/models'; import { callbacks } from '../../../../server/lib/callbacks'; - -export async function afterSaveMessage(message: IMessage, room: IRoom, user: IUser, roomUpdater?: Updater): Promise { +import type { SendMessageOptions } from '../functions/sendMessage'; + +export async function afterSaveMessage( + message: IMessage, + room: IRoom, + user: IUser, + { + roomUpdater, + options, + }: { + roomUpdater?: Updater; + options?: SendMessageOptions; + } = {}, +): Promise { const updater = roomUpdater ?? Rooms.getUpdater(); - const data: IMessage = (await callbacks.run('afterSaveMessage', message, { room, user, roomUpdater: updater })) as unknown as IMessage; + + const data: IMessage = (await callbacks.run('afterSaveMessage', message, { + room, + user, + roomUpdater: updater, + options, + })) as unknown as IMessage; if (!roomUpdater && updater.hasChanges()) { await Rooms.updateFromUpdater({ _id: room._id }, updater); @@ -19,8 +37,21 @@ export async function afterSaveMessage(message: IMessage, room: IRoom, user: IUs return data; } -export function afterSaveMessageAsync(message: IMessage, room: IRoom, user: IUser, roomUpdater: Updater = Rooms.getUpdater()): void { - callbacks.runAsync('afterSaveMessage', message, { room, user, roomUpdater }); +export function afterSaveMessageAsync( + message: IMessage, + room: IRoom, + user: IUser, + { + roomUpdater: updater, + options, + }: { + roomUpdater?: Updater; + options?: SendMessageOptions; + } = {}, +): void { + const roomUpdater = updater ?? Rooms.getUpdater(); + + callbacks.runAsync('afterSaveMessage', message, { room, user, roomUpdater, options }); if (roomUpdater.hasChanges()) { void Rooms.updateFromUpdater({ _id: room._id }, roomUpdater); diff --git a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts index ae6255a5dfd18..7a089abba0815 100644 --- a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts +++ b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts @@ -417,7 +417,13 @@ settings.watch('Troubleshoot_Disable_Notifications', (value) => { callbacks.add( 'afterSaveMessage', - (message, { room }) => sendAllNotifications(message, room), + (message, { room, options }) => { + if (options?.skipNotifications) { + return message; + } + + return sendAllNotifications(message, room); + }, callbacks.priority.LOW, 'sendNotificationsOnMessage', ); 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/app/threads/server/hooks/aftersavemessage.ts b/apps/meteor/app/threads/server/hooks/aftersavemessage.ts index 5a3eea24f47b3..fd63a30d64c69 100644 --- a/apps/meteor/app/threads/server/hooks/aftersavemessage.ts +++ b/apps/meteor/app/threads/server/hooks/aftersavemessage.ts @@ -4,6 +4,7 @@ import { Messages } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../server/lib/callbacks'; +import type { SendMessageOptions } from '../../../lib/server/functions/sendMessage'; import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; import { updateThreadUsersSubscriptions, getMentions } from '../../../lib/server/lib/notifyUsersOnMessage'; import { sendMessageNotifications } from '../../../lib/server/lib/sendNotificationsOnMessage'; @@ -39,7 +40,7 @@ const notification = async (message: IMessage, room: IRoom, replies: string[]) = return message; }; -export async function processThreads(message: IMessage, room: IRoom) { +export async function processThreads(message: IMessage, room: IRoom, options?: SendMessageOptions) { if (!message.tmid) { return message; } @@ -61,7 +62,9 @@ export async function processThreads(message: IMessage, room: IRoom) { await notifyUsersOnReply(message, replies); await metaData(message, parentMessage, replies); - await notification(message, room, replies); + if (!options?.skipNotifications) { + await notification(message, room, replies); + } void notifyOnMessageChange({ id: message.tmid, }); @@ -77,8 +80,8 @@ Meteor.startup(() => { } callbacks.add( 'afterSaveMessage', - async (message, { room }) => { - return processThreads(message, room); + async (message, { room, options }) => { + return processThreads(message, room, options); }, callbacks.priority.LOW, 'threads-after-save-message', diff --git a/apps/meteor/server/lib/callbacks.ts b/apps/meteor/server/lib/callbacks.ts index d9fa18e0491b7..e33de67c51fae 100644 --- a/apps/meteor/server/lib/callbacks.ts +++ b/apps/meteor/server/lib/callbacks.ts @@ -24,6 +24,7 @@ import type { FilterOperators } from 'mongodb'; import { Callbacks } from './callbacks/callbacksBase'; import type { ILoginAttempt } from '../../app/authentication/server/ILoginAttempt'; +import type { SendMessageOptions } from '../../app/lib/server/functions/sendMessage'; import type { IBusinessHourBehavior } from '../../app/livechat/server/business-hour/AbstractBusinessHour'; import type { CloseRoomParams } from '../../app/livechat/server/lib/localTypes'; @@ -46,7 +47,10 @@ interface EventLikeCallbackSignatures { 'afterDeleteUser': (user: IUser) => void; 'afterFileUpload': (params: { user: IUser; room: IRoom; message: IMessage }) => void; 'afterRoomNameChange': (params: { room: IRoom; name: string; oldName: string; user: IUser }) => void; - 'afterSaveMessage': (message: IMessage, params: { room: IRoom; user: IUser; roomUpdater?: Updater }) => void; + 'afterSaveMessage': ( + message: IMessage, + params: { room: IRoom; user: IUser; roomUpdater?: Updater; options?: SendMessageOptions }, + ) => void; 'afterOmnichannelSaveMessage': (message: IMessage, constant: { room: IOmnichannelRoom; roomUpdater: Updater }) => void; 'livechat.removeAgentDepartment': (params: { departmentId: ILivechatDepartmentRecord['_id']; agentsId: ILivechatAgent['_id'][] }) => void; 'livechat.saveAgentDepartment': (params: { departmentId: ILivechatDepartmentRecord['_id']; agentsId: ILivechatAgent['_id'][] }) => void; 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..77a4e7c36292f --- /dev/null +++ b/apps/meteor/server/services/media-call/push/sendVoipPushNotification.ts @@ -0,0 +1,117 @@ +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 { 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 { + 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; + } + + // 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 { + kind, + callee: { id: userId }, + } = call; + 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 c500a41968d11..213a20bdb7881 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -7,19 +7,20 @@ import type { CallHistoryItemState, IExternalMediaCallHistoryItem, } from '@rocket.chat/core-typings'; -import { Logger } from '@rocket.chat/logger'; import { callServer, type IMediaCallServerSettings } from '@rocket.chat/media-calls'; -import { type CallFeature, isClientMediaSignal, type ClientMediaSignal, type ServerMediaSignal } from '@rocket.chat/media-signaling'; +import type { CallFeature, ClientMediaSignal, ServerMediaSignal, ClientMediaSignalAnswer } from '@rocket.chat/media-signaling'; +import { isClientMediaSignal } from '@rocket.chat/media-signaling'; import type { InsertionModel } from '@rocket.chat/model-typings'; import { CallHistory, MediaCalls, Rooms, Users } from '@rocket.chat/models'; -import { getHistoryMessagePayload } from '@rocket.chat/ui-voip/dist/ui-kit/getHistoryMessagePayload'; +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'; @@ -28,6 +29,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 => { @@ -39,21 +41,72 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall this.configureMediaCallServer(); } + public async answerCall(uid: IUser['_id'], params: Omit): Promise { + const { callId, answer } = params; + + const call = await MediaCalls.findOneByIdAndCallee>( + callId, + { type: 'user', id: uid }, + { projection: { _id: 1 } }, + ); + if (!call) { + throw new Error('not-found'); + } + + const signal: ClientMediaSignalAnswer = { + type: 'answer', + ...params, + }; + + await callServer.receiveSignal(uid, signal, { throwIfSkipped: true }); + + const updatedCall = await MediaCalls.findOneById(callId); + if (!updatedCall) { + throw new Error('internal-error'); + } + + switch (answer) { + case 'ack': + if (updatedCall.acceptedAt || updatedCall.ended) { + throw new Error('invalid-call-state'); + } + break; + case 'reject': + if (!updatedCall.ended || updatedCall.endedBy?.id !== uid) { + throw new Error('invalid-call-state'); + } + break; + case 'accept': + if (updatedCall.callee.contractId !== signal.contractId) { + if (updatedCall.callee.contractId) { + throw new Error('invalid-call-state'); + } + throw new Error('internal-error'); + } + break; + } + + return updatedCall; + } + public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { try { - callServer.receiveSignal(uid, signal); + await callServer.receiveSignal(uid, signal); } catch (err) { logger.error({ msg: 'failed to process client signal', err, signal, uid }); } } public async processSerializedSignal(uid: IUser['_id'], signal: string): Promise { + let signalType: string | null = null; + try { const deserialized = await this.deserializeClientSignal(signal); + signalType = deserialized.type; - callServer.receiveSignal(uid, deserialized); + await callServer.receiveSignal(uid, deserialized); } catch (err) { - logger.error({ msg: 'failed to process client signal', err, uid }); + logger.error({ msg: 'failed to process client signal', err, uid, type: signalType }); } } @@ -187,6 +240,10 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall } } + private getLanguageForUser(user: IUser): string { + return user.language || settings.get('Language') || 'en'; + } + private async sendHistoryMessage(call: IMediaCall, room: IRoom): Promise { const userId = call.caller.id || call.createdBy?.id; // I think this should always be the caller, since during a transfer the createdBy contact is the one that transferred the call @@ -196,12 +253,16 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall } const state = this.getCallHistoryItemState(call); + const skipNotifications = state !== 'not-answered' || call.hangupReason === 'rejected'; + const i18nKey = callStateToTranslationKey(state).i18n?.key; + + const msg = i18nKey ? i18n.t(i18nKey, { lng: this.getLanguageForUser(user) }) : ''; const duration = this.getCallDuration(call); - const record = getHistoryMessagePayload(state, duration, call._id); + const record = getHistoryMessagePayload(state, duration, call._id, msg); try { - const message = await sendMessage(user, record, room); + const message = await sendMessage(user, record, room, { skipNotifications }); if ('_id' in message) { await CallHistory.updateMany({ callId: call._id }, { $set: { messageId: message._id } }); diff --git a/ee/packages/media-calls/src/base/BaseAgent.ts b/ee/packages/media-calls/src/base/BaseAgent.ts index 05f3fa76dba54..7cdee4cdb98f4 100644 --- a/ee/packages/media-calls/src/base/BaseAgent.ts +++ b/ee/packages/media-calls/src/base/BaseAgent.ts @@ -63,7 +63,7 @@ export abstract class BaseMediaCallAgent implements IMediaCallAgent { public abstract onCallAccepted(callId: string, data: { signedContractId: string; features: CallFeature[] }): Promise; - public abstract onCallActive(callId: string): Promise; + public abstract onCallActive(callId: string, data?: { signedContractId?: string }): Promise; public abstract onCallEnded(callId: string): Promise; @@ -73,6 +73,8 @@ export abstract class BaseMediaCallAgent implements IMediaCallAgent { public abstract onCallCreated(call: IMediaCall): Promise; + public abstract onCallTrying(callId: string): Promise; + public abstract onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise; public abstract onCallTransferred(callId: string): Promise; diff --git a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts index bc320aa2fb73e..3830493949ae2 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts @@ -15,6 +15,7 @@ export interface IMediaCallAgent { onCallAccepted(callId: string, data: { signedContractId: string; features: CallFeature[] }): Promise; onCallActive(callId: string): Promise; onCallCreated(call: IMediaCall): Promise; + onCallTrying(callId: string): Promise; /* Called when the sdp of the other actor is available, regardless of call state, or when this actor must provide an offer */ onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise; diff --git a/ee/packages/media-calls/src/definition/IMediaCallServer.ts b/ee/packages/media-calls/src/definition/IMediaCallServer.ts index 9d80fd42d33a4..5454bbbab9a74 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallServer.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallServer.ts @@ -2,12 +2,16 @@ import type { IUser } from '@rocket.chat/core-typings'; import type { Emitter } from '@rocket.chat/emitter'; import type { CallFeature, ClientMediaSignal, ClientMediaSignalBody, ServerMediaSignal } from '@rocket.chat/media-signaling'; -import type { InternalCallParams } from './common'; +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 { @@ -40,9 +44,10 @@ 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): void; + receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal, options?: SignalProcessingOptions): Promise; receiveCallUpdate(params: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }): void; // extra functions available to the service diff --git a/ee/packages/media-calls/src/definition/common.ts b/ee/packages/media-calls/src/definition/common.ts index b9683a6d6a3fc..07dbbb0677969 100644 --- a/ee/packages/media-calls/src/definition/common.ts +++ b/ee/packages/media-calls/src/definition/common.ts @@ -28,3 +28,9 @@ export class CallRejectedError extends Error { super(message || 'call-rejected'); } } + +export type SignalProcessingOptions = { + // Some signals can be safely skipped when they are not relevant to the current call state, but + // if the signal was received via REST, we shouldn't return success without processing anything, so we throw an error instead + throwIfSkipped?: boolean; +}; diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index 35d9b5546198e..52e0a416f7f23 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -2,6 +2,7 @@ import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { isPendingState } from '@rocket.chat/media-signaling'; import type { + CallFeature, ClientMediaSignal, ClientMediaSignalRegister, ClientMediaSignalRequestCall, @@ -11,7 +12,7 @@ import type { import { MediaCalls } from '@rocket.chat/models'; import { DEFAULT_CALL_FEATURES } from '../constants'; -import type { InternalCallParams } from '../definition/common'; +import type { InternalCallParams, SignalProcessingOptions } from '../definition/common'; import { logger } from '../logger'; import { mediaCallDirector } from '../server/CallDirector'; import { UserActorAgent } from './agents/UserActorAgent'; @@ -30,7 +31,7 @@ export class GlobalSignalProcessor { this.emitter = new Emitter(); } - public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { + public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal, options: SignalProcessingOptions): Promise { switch (signal.type) { case 'register': return this.processRegisterSignal(uid, signal); @@ -39,7 +40,7 @@ export class GlobalSignalProcessor { } if ('callId' in signal) { - return this.processCallSignal(uid, signal); + return this.processCallSignal(uid, signal, options); } logger.error({ msg: 'Unrecognized media signal', signal: stripSensitiveDataFromSignal(signal) }); @@ -56,6 +57,7 @@ export class GlobalSignalProcessor { private async processCallSignal( uid: IUser['_id'], signal: Exclude, + { throwIfSkipped }: SignalProcessingOptions, ): Promise { try { const call = await MediaCalls.findOneById(signal.callId); @@ -91,6 +93,9 @@ export class GlobalSignalProcessor { // Ignore signals from different sessions if the actor is already signed if (!skipContractCheck && callActor.contractId && callActor.contractId !== signal.contractId) { + if (throwIfSkipped) { + throw new Error('invalid-contract'); + } return; } @@ -100,7 +105,14 @@ export class GlobalSignalProcessor { const { [role]: agent } = agents; if (!(agent instanceof UserActorAgent)) { - throw new Error('Actor agent is not prepared to process signals'); + logger.error({ + msg: 'Actor agent is not prepared to process signals', + method: 'processSignal', + signal: stripSensitiveDataFromSignal(signal), + isCaller, + isCallee, + }); + throw new Error('internal-error'); } await agent.processSignal(call, signal); @@ -114,6 +126,18 @@ export class GlobalSignalProcessor { logger.debug({ msg: 'GlobalSignalProcessor.processRegisterSignal', signal: stripSensitiveDataFromSignal(signal), uid }); const calls = await MediaCalls.findAllNotOverByUid(uid).toArray(); + const signedCalls = calls.filter( + ({ callee, caller }) => + (callee.type === 'user' && callee.id === uid && callee.contractId === signal.contractId) || + (caller.type === 'user' && caller.id === uid && caller.contractId === signal.contractId), + ); + + this.sendSignal(uid, { + type: 'registered', + toContractId: signal.contractId, + activeCalls: signedCalls.map(({ _id }) => _id), + }); + if (!calls.length) { return; } @@ -148,22 +172,30 @@ export class GlobalSignalProcessor { await mediaCallDirector.renewCallId(call._id); } - this.sendSignal(uid, buildNewCallSignal(call, role)); + const agents = await mediaCallDirector.cast.getAgentsFromCall(call); + const { [role]: agent } = agents; - if (call.state === 'active') { - this.sendSignal(uid, { - callId: call._id, - type: 'notification', - notification: 'active', - ...(actor.contractId && { signedContractId: actor.contractId }), + await agent.oppositeAgent?.onCallTrying(call._id); + + if (!(agent instanceof UserActorAgent)) { + logger.error({ + msg: 'Actor agent is not prepared to process signals', + method: 'reactToUnknownCall', + signal: stripSensitiveDataFromSignal(signal), + isCaller, + isCallee, }); + throw new Error('internal-error'); + } + + agent.disablePushNotifications(); + + await agent.onCallCreated(call); + + if (call.state === 'active') { + await agent.onCallActive(call._id, actor.contractId ? { signedContractId: actor.contractId } : undefined); } else if (actor.contractId && !isPendingState(call.state)) { - this.sendSignal(uid, { - callId: call._id, - type: 'notification', - notification: 'accepted', - signedContractId: actor.contractId, - }); + await agent.onCallAccepted(call._id, { signedContractId: actor.contractId, features: call.features as CallFeature[] }); } } diff --git a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index ae8b1f29ab319..fa2d8dfa875c1 100644 --- a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -22,6 +22,7 @@ import { MediaCallChannels, MediaCallNegotiations, MediaCalls } from '@rocket.ch import { DEFAULT_CALL_FEATURES } from '../../constants'; import type { IMediaCallAgent } from '../../definition/IMediaCallAgent'; +import type { SignalProcessingOptions } from '../../definition/common'; import { logger } from '../../logger'; import { mediaCallDirector } from '../../server/CallDirector'; import { getMediaCallServer } from '../../server/injection'; @@ -60,6 +61,8 @@ export class UserActorSignalProcessor { public readonly ignored: boolean; + private throwIfSkipped: boolean; + constructor( protected readonly agent: IMediaCallAgent, protected readonly call: IMediaCall, @@ -69,6 +72,7 @@ export class UserActorSignalProcessor { this.signed = Boolean(actor.contractId && actor.contractId === channel.contractId); this.ignored = Boolean(actor.contractId && actor.contractId !== channel.contractId); + this.throwIfSkipped = false; } public async requestWebRTCOffer(params: { negotiationId: string }): Promise { @@ -82,7 +86,7 @@ export class UserActorSignalProcessor { }); } - public async processSignal(signal: ClientMediaSignal): Promise { + public async processSignal(signal: ClientMediaSignal, options: SignalProcessingOptions = {}): Promise { if (signal.type !== 'local-state') { logger.debug({ msg: 'UserActorSignalProcessor.processSignal', @@ -92,6 +96,8 @@ export class UserActorSignalProcessor { }); } + this.throwIfSkipped = options.throwIfSkipped || false; + // The code will only reach this point if one of the following conditions are true: // 1. the signal came from the exact user session where the caller initiated the call // 2. the signal came from the exact user session where the callee accepted the call @@ -296,13 +302,11 @@ export class UserActorSignalProcessor { } protected async clientHasRejected(): Promise { - if (!this.isCallPending()) { + if (!this.validatePendingCallee()) { return; } - if (this.role === 'callee') { - return mediaCallDirector.hangup(this.call, this.agent, 'rejected'); - } + return mediaCallDirector.hangup(this.call, this.agent, 'rejected'); } protected async clientIsUnavailable(): Promise { @@ -315,13 +319,11 @@ export class UserActorSignalProcessor { } protected async clientHasAccepted(supportedFeatures: CallFeature[]): Promise { - if (!this.isCallPending()) { + if (!this.validatePendingCallee()) { return; } - if (this.role === 'callee') { - await mediaCallDirector.acceptCall(this.call, this.agent, { calleeContractId: this.contractId, supportedFeatures }); - } + await mediaCallDirector.acceptCall(this.call, this.agent, { calleeContractId: this.contractId, supportedFeatures }); } protected async clientIsActive(): Promise { @@ -344,6 +346,24 @@ export class UserActorSignalProcessor { return ['active', 'hangup'].includes(this.call.state); } + protected validatePendingCallee(): boolean { + if (this.role !== 'callee') { + if (this.throwIfSkipped) { + throw new Error('invalid-call-role'); + } + return false; + } + + if (!this.isCallPending()) { + if (this.throwIfSkipped) { + throw new Error('invalid-call-state'); + } + return false; + } + + return true; + } + private async reviewLocalState(signal: ClientMediaSignalLocalState): Promise { if (!this.signed) { return; diff --git a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index 4a8623ac65776..2b7eed02a1466 100644 --- a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -5,11 +5,18 @@ import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import { UserActorSignalProcessor } from './CallSignalProcessor'; import { BaseMediaCallAgent } from '../../base/BaseAgent'; +import type { VoipPushNotificationEventType } from '../../definition/IMediaCallServer'; import { logger } from '../../logger'; import { buildNewCallSignal } from '../../server/buildNewCallSignal'; import { getMediaCallServer } from '../../server/injection'; export class UserActorAgent extends BaseMediaCallAgent { + private pushNotificationsEnabled = true; + + public disablePushNotifications(): void { + this.pushNotificationsEnabled = false; + } + public async processSignal(call: IMediaCall, signal: ClientMediaSignal): Promise { const channel = await this.getOrCreateChannel(call, signal.contractId); @@ -33,12 +40,19 @@ export class UserActorAgent extends BaseMediaCallAgent { return; } + this.sendPushNotification({ callId, event: 'answer' }); + const negotiation = await MediaCallNegotiations.findLatestByCallId(callId); if (!negotiation?.offer) { logger.debug('The call was accepted but the webrtc offer is not yet available.'); return; } + if (negotiation.offerer !== 'caller') { + logger.debug('onCallAccepted event was triggered with a renegotiation already in place.'); + return; + } + await this.sendSignal({ callId, toContractId: data.signedContractId, @@ -50,6 +64,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', @@ -57,11 +75,12 @@ export class UserActorAgent extends BaseMediaCallAgent { }); } - public async onCallActive(callId: string): Promise { + public async onCallActive(callId: string, data: { signedContractId?: string } = {}): Promise { return this.sendSignal({ callId, type: 'notification', notification: 'active', + ...(data.signedContractId && { signedContractId: data.signedContractId }), }); } @@ -72,6 +91,10 @@ export class UserActorAgent extends BaseMediaCallAgent { } await this.sendSignal(buildNewCallSignal(call, this.role)); + + if (this.role === 'callee') { + this.sendPushNotification({ callId: call._id, event: 'new' }); + } } public async onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise { @@ -155,8 +178,24 @@ export class UserActorAgent extends BaseMediaCallAgent { }); } + public async onCallTrying(callId: string): Promise { + await this.sendSignal({ + callId, + type: 'notification', + notification: 'trying', + }); + } + public async onDTMF(callId: string, dtmf: string, duration: number): Promise { 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 { + if (!this.pushNotificationsEnabled) { + return; + } + + getMediaCallServer().sendPushNotification(params); + } } diff --git a/ee/packages/media-calls/src/server/BroadcastAgent.ts b/ee/packages/media-calls/src/server/BroadcastAgent.ts index 5b76704dd799c..1827418ab60aa 100644 --- a/ee/packages/media-calls/src/server/BroadcastAgent.ts +++ b/ee/packages/media-calls/src/server/BroadcastAgent.ts @@ -43,6 +43,10 @@ export class BroadcastActorAgent extends BaseMediaCallAgent { this.reportCallUpdated({ callId, dtmf: { dtmf, duration } }); } + public async onCallTrying(_callId: string): Promise { + // No need to broadcast trying signals as this doesn't change anything on the call data + } + protected reportCallUpdated(params: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }): void { const { callId, ...otherParams } = params; diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index a3f63267650b1..f191ed3a741af 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, @@ -204,8 +206,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 6ca629c9c52fb..ffafa9de0be62 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -1,6 +1,5 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; -import { isClientMediaSignal } from '@rocket.chat/media-signaling'; import type { CallFeature, CallRejectedReason, @@ -12,8 +11,14 @@ import type { import { mediaCallDirector } from './CallDirector'; import { getDefaultSettings } from './getDefaultSettings'; import { stripSensitiveDataFromSignal } from './stripSensitiveData'; -import type { IMediaCallServer, IMediaCallServerSettings, MediaCallServerEvents } from '../definition/IMediaCallServer'; -import { CallRejectedError, type GetActorContactOptions, type InternalCallParams } from '../definition/common'; +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'; import { GlobalSignalProcessor } from '../internal/SignalProcessor'; import { logger } from '../logger'; @@ -47,15 +52,8 @@ export class MediaCallServer implements IMediaCallServer { }); } - public receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): void { - if (!isClientMediaSignal(signal)) { - logger.error({ msg: 'The Media Signal Server received an invalid client signal object' }); - throw new Error('invalid-signal'); - } - - this.signalProcessor.processSignal(fromUid, signal).catch((err) => { - logger.error({ msg: 'Failed to process client signal', err, type: signal.type }); - }); + public async receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal, options: SignalProcessingOptions = {}): Promise { + return this.signalProcessor.processSignal(fromUid, signal, options); } public sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void { @@ -76,6 +74,12 @@ export class MediaCallServer implements IMediaCallServer { this.emitter.emit('historyUpdate', params); } + public sendPushNotification(params: { callId: string; event: VoipPushNotificationEventType }): void { + 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/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index a00e76a034d82..dcd7004937bf8 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -1,7 +1,8 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import type { ClientMediaSignal } from '@rocket.chat/media-signaling'; +import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; +import type { ClientMediaSignal, ClientMediaSignalAnswer } from '@rocket.chat/media-signaling'; export interface IMediaCallService { + answerCall(uid: IUser['_id'], params: Omit): Promise; processSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): Promise; processSerializedSignal(fromUid: IUser['_id'], signal: string): Promise; hangupExpiredCalls(): Promise; diff --git a/packages/core-typings/src/IPushNotificationConfig.ts b/packages/core-typings/src/IPushNotificationConfig.ts index 3279d42aa7b2c..5c5c5fdac29dd 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; @@ -14,6 +16,8 @@ export interface IPushNotificationConfig { }; apn?: { category: string; - topicSuffix?: 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/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index b2856303abd06..0ba947f449c1f 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -51,16 +51,20 @@ export type CallHangupReason = | 'unknown' // One of the call's signed users reported they don't know this call | 'another-client'; // One of the call's users requested a hangup from a different client session than the one where the call is happening -export type CallAnswer = - | 'accept' // actor accepts the call - | 'reject' // actor rejects the call - | 'ack' // agent confirms the actor is reachable - | 'unavailable'; // agent reports the actor is unavailable +export const callAnswerList = [ + 'accept', // actor accepts the call + 'reject', // actor rejects the call + 'ack', // agent confirms the actor is reachable + 'unavailable', // agent reports the actor is unavailable +] as const; + +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/definition/signals/client/answer.ts b/packages/media-signaling/src/definition/signals/client/answer.ts index 9b14771a1cda3..c48d7f90ada07 100644 --- a/packages/media-signaling/src/definition/signals/client/answer.ts +++ b/packages/media-signaling/src/definition/signals/client/answer.ts @@ -1,7 +1,7 @@ import type { JSONSchemaType } from 'ajv'; import type { CallAnswer, CallFeature } from '../../call'; -import { callFeatureList } from '../../call/IClientMediaCall'; +import { callAnswerList, callFeatureList } from '../../call/IClientMediaCall'; /** Client is saying that the user accepted or rejected a call, or simply reporting that the user can or can't be reached */ export type ClientMediaSignalAnswer = { @@ -33,7 +33,7 @@ export const clientMediaSignalAnswerSchema: JSONSchemaType; + reset: () => void; + clear: () => void; }; export class ClientMediaCall implements IClientMediaCall { @@ -165,6 +166,8 @@ export class ClientMediaCall implements IClientMediaCall { private acceptedLocally: boolean; + private acceptedRemotely: boolean; + private endedLocally: boolean; private hasRemoteData: boolean; @@ -175,7 +178,7 @@ export class ClientMediaCall implements IClientMediaCall { private earlySignals: Set; - private stateTimeoutHandlers: Set; + private stateTimeoutHandlers: Set; private remoteCallId: string | null; @@ -229,6 +232,7 @@ export class ClientMediaCall implements IClientMediaCall { this.remoteCallId = null; this.acceptedLocally = false; + this.acceptedRemotely = false; this.endedLocally = false; this.hasRemoteData = false; this.initialized = false; @@ -588,6 +592,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') { @@ -1061,6 +1071,9 @@ export class ClientMediaCall implements IClientMediaCall { this.changeState('active'); } return; + case 'trying': + this.resetStateTimeouts(); + break; case 'hangup': return this.flagAsEnded('remote'); @@ -1068,7 +1081,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; @@ -1080,16 +1098,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'); } @@ -1117,26 +1134,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 { @@ -1155,6 +1185,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(); @@ -1164,14 +1207,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 b3f4f80668d2c..1e2118f355589 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -8,10 +8,14 @@ import type { MediaSignalTransport, MediaStreamFactory, RandomStringFactory, + ServerMediaCallSignal, + ServerMediaSessionSignal, ServerMediaSignal, + ServerMediaSignalRegistered, } from '../definition'; import type { IClientMediaCall, CallActorType, CallContact, CallFeature } from '../definition/call'; import type { IMediaSignalLogger } from '../definition/logger'; +import { SessionRegistration } from './components/SessionRegistration'; export type MediaSignalingEvents = { sessionStateChange: void; @@ -19,6 +23,7 @@ export type MediaSignalingEvents = { acceptedCall: { call: IClientMediaCall }; endedCall: void; hiddenCall: void; + registered: { activeCalls: IClientMediaCall['callId'][] }; }; export type MediaSignalingSessionConfig = { @@ -65,6 +70,10 @@ export class MediaSignalingSession extends Emitter { private lastState: { hasCall: boolean; hasVisibleCall: boolean; hasBusyCall: boolean }; + private sessionEnded = false; + + private registration: SessionRegistration; + public get sessionId(): string { return this._sessionId; } @@ -73,6 +82,10 @@ export class MediaSignalingSession extends Emitter { return this._userId; } + public get registered(): boolean { + return this.registration.registered; + } + constructor(private config: MediaSignalingSessionConfig) { super(); this._userId = config.userId; @@ -88,6 +101,10 @@ export class MediaSignalingSession extends Emitter { this.lastState = { hasCall: false, hasVisibleCall: false, hasBusyCall: false }; this.transporter = new MediaSignalTransportWrapper(this._sessionId, config.transport, config.logger); + this.registration = new SessionRegistration({ + logger: config.logger, + registerFn: () => this.sendRegisterSignal(), + }); this.register(); this.enableStateReport(STATE_REPORT_INTERVAL); @@ -113,6 +130,8 @@ export class MediaSignalingSession extends Emitter { } public endSession(): void { + this.sessionEnded = true; + this.registration.sessionEnded = true; this.disableStateReport(); // best‑effort: stop capturing audio @@ -160,29 +179,15 @@ export class MediaSignalingSession extends Emitter { } public async processSignal(signal: ServerMediaSignal): Promise { - this.config.logger?.debug('MediaSignalingSession.processSignal', signal); - - if (this.isCallIgnored(signal.callId)) { + if (this.sessionEnded) { return; } - - const call = this.getOrCreateCallBySignal(signal); - - if (signal.type === 'notification' && signal.signedContractId) { - if (signal.signedContractId === this._sessionId) { - call.setContractState('signed'); - } else if (signal.notification === 'accepted') { - // The server accepted a contract, but it wasn't ours - ignore the call in this session - call.setContractState('ignored'); - } - } else if ('toContractId' in signal) { - call.setContractState(signal.toContractId === this._sessionId ? 'signed' : 'ignored'); - } else if (signal.type === 'new' && signal.self.contractId) { - call.setContractState(signal.self.contractId === this._sessionId ? 'signed' : 'ignored'); + this.config.logger?.debug('MediaSignalingSession.processSignal', signal); + if ('callId' in signal) { + return this.processCallSignal(signal); } - const oldCall = this.getReplacedCallBySignal(signal); - await call.processSignal(signal, oldCall); + return this.processSessionSignal(signal); } public async setDeviceId(deviceId: ConstrainDOMString | null): Promise { @@ -215,13 +220,7 @@ export class MediaSignalingSession extends Emitter { } public register(): void { - this.lastRegisterTimestamp = new Date(); - - this.transporter.sendSignal({ - type: 'register', - contractId: this._sessionId, - ...(this.config.oldSessionId && { oldContractId: this.config.oldSessionId }), - }); + this.registration.reRegister(); } public setIceGatheringTimeout(newTimeout: number): void { @@ -252,7 +251,7 @@ export class MediaSignalingSession extends Emitter { } } - private getExistingCallBySignal(signal: ServerMediaSignal): ClientMediaCall | null { + private getExistingCallBySignal(signal: ServerMediaCallSignal): ClientMediaCall | null { const existingCall = this.knownCalls.get(signal.callId); if (existingCall) { return existingCall; @@ -278,7 +277,7 @@ export class MediaSignalingSession extends Emitter { return null; } - private getOrCreateCallBySignal(signal: ServerMediaSignal): ClientMediaCall { + private getOrCreateCallBySignal(signal: ServerMediaCallSignal): ClientMediaCall { this.config.logger?.debug('MediaSignalingSession.getOrCreateCallBySignal', signal); const existingCall = this.getExistingCallBySignal(signal); if (existingCall) { @@ -328,7 +327,7 @@ export class MediaSignalingSession extends Emitter { } } - this.register(); + this.registration.register(); } private async setInputTrack(newInputTrack: MediaStreamTrack | null): Promise { @@ -524,6 +523,62 @@ export class MediaSignalingSession extends Emitter { await this.setScreenVideoTrack(track, call); } + private sendRegisterSignal(): void { + this.lastRegisterTimestamp = new Date(); + this.transporter.sendSignal({ + type: 'register', + contractId: this._sessionId, + ...(this.config.oldSessionId && { oldContractId: this.config.oldSessionId }), + }); + } + + private async processCallSignal(signal: ServerMediaCallSignal): Promise { + if (this.isCallIgnored(signal.callId)) { + return; + } + + const call = this.getOrCreateCallBySignal(signal); + + if (signal.type === 'notification' && signal.signedContractId) { + if (signal.signedContractId === this._sessionId) { + call.setContractState('signed'); + } else if (signal.notification === 'accepted') { + // The server accepted a contract, but it wasn't ours - ignore the call in this session + call.setContractState('ignored'); + } + } else if ('toContractId' in signal) { + call.setContractState(signal.toContractId === this._sessionId ? 'signed' : 'ignored'); + } else if (signal.type === 'new' && signal.self.contractId) { + call.setContractState(signal.self.contractId === this._sessionId ? 'signed' : 'ignored'); + } + + const oldCall = this.getReplacedCallBySignal(signal); + await call.processSignal(signal, oldCall); + } + + private processSessionSignal(signal: ServerMediaSessionSignal): void { + if (signal.toContractId !== this._sessionId) { + return; + } + + switch (signal.type) { + case 'registered': + return this.confirmSessionRegistered(signal); + } + } + + private confirmSessionRegistered(signal: ServerMediaSignalRegistered): void { + this.config.logger?.debug('MediaSignalingSession.sessionRegistered'); + const wasRegistered = this.registered; + this.registration.confirmRegistration(); + + this.emit('registered', { activeCalls: signal.activeCalls }); + + if (!wasRegistered) { + this.onSessionStateChange(); + } + } + private createCall(callId: string): ClientMediaCall { this.config.logger?.debug('MediaSignalingSession.createCall'); const config = { @@ -625,6 +680,15 @@ export class MediaSignalingSession extends Emitter { } private onSessionStateChange(): void { + if (this.sessionEnded) { + return; + } + + if (!this.registered) { + this.config.logger?.debug('skipping session events on unregistered session'); + return; + } + const hadCall = this.lastState.hasCall; const hadVisibleCall = this.lastState.hasVisibleCall; const hadBusyCall = this.lastState.hasBusyCall; diff --git a/packages/media-signaling/src/lib/components/SessionRegistration.ts b/packages/media-signaling/src/lib/components/SessionRegistration.ts new file mode 100644 index 0000000000000..db8500e62af03 --- /dev/null +++ b/packages/media-signaling/src/lib/components/SessionRegistration.ts @@ -0,0 +1,73 @@ +import type { IMediaSignalLogger } from '../../definition'; + +const REGISTER_CONFIRMATION_TIMEOUT = 1000; +const MAX_REGISTER_ATTEMPTS = 10; + +type SessionRegistrationConfig = { + logger?: IMediaSignalLogger; + registerFn: () => void; +}; + +export class SessionRegistration { + public sessionEnded = false; + + public get registered(): boolean { + return this.registrationConfirmed; + } + + private registrationConfirmed = false; + + private registerConfirmationHandler: ReturnType | null = null; + + constructor(private config: SessionRegistrationConfig) { + // + } + + public register(): void { + if (this.registerConfirmationHandler) { + return; + } + + this.registerAttempt(1); + } + + public reRegister(): void { + if (this.sessionEnded) { + return; + } + + this.config.logger?.debug('SessionRegistration.reRegister'); + this.clearRegisterConfirmationHandler(); + this.register(); + } + + public confirmRegistration(): void { + this.registrationConfirmed = true; + + this.clearRegisterConfirmationHandler(); + } + + private clearRegisterConfirmationHandler(): void { + if (this.registerConfirmationHandler) { + clearTimeout(this.registerConfirmationHandler); + this.registerConfirmationHandler = null; + } + } + + private registerAttempt(attempt: number): void { + if (this.sessionEnded) { + return; + } + this.config.logger?.debug('SessionRegistration.registerAttempt', attempt); + const timeout = attempt * REGISTER_CONFIRMATION_TIMEOUT; + + this.registerConfirmationHandler = setTimeout(() => { + this.registerConfirmationHandler = null; + if (attempt < MAX_REGISTER_ATTEMPTS) { + this.registerAttempt(attempt + 1); + } + }, timeout); + + this.config.registerFn(); + } +} diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index a290fc73c3f33..2221a5335d226 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -1,9 +1,21 @@ -import type { IMediaCall, IUser, MediaCallActorType, MediaCallContact, MediaCallSignedContact } from '@rocket.chat/core-typings'; +import type { + IMediaCall, + IUser, + MediaCallActor, + MediaCallActorType, + MediaCallContact, + MediaCallSignedContact, +} from '@rocket.chat/core-typings'; import type { Document, FindCursor, FindOptions, UpdateResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; export interface IMediaCallsModel extends IBaseModel { + findOneByIdAndCallee( + id: IMediaCall['_id'], + callee: MediaCallActor, + options?: FindOptions, + ): Promise; findOneByCallerRequestedId( id: Required['callerRequestedId'], caller: { type: MediaCallActorType; id: string }, 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/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index ab02a344fb91f..858ea9a9680ec 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -5,6 +5,7 @@ import type { MediaCallSignedContact, MediaCallContact, IUser, + MediaCallActor, } from '@rocket.chat/core-typings'; import type { IMediaCallsModel } from '@rocket.chat/model-typings'; import type { @@ -37,6 +38,22 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ]; } + public async findOneByIdAndCallee( + id: IMediaCall['_id'], + callee: MediaCallActor, + options?: FindOptions, + ): Promise { + return this.findOne( + { + '_id': id, + 'callee.type': callee.type, + 'callee.id': callee.id, + ...(callee.contractId && { 'callee.contractId': callee.contractId }), + }, + options, + ); + } + public async findOneByCallerRequestedId( id: Required['callerRequestedId'], caller: { type: MediaCallActorType; id: string }, 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, + }, + }, + ); + } } diff --git a/packages/ui-voip/src/ui-kit/getHistoryMessagePayload.spec.ts b/packages/ui-voip/src/ui-kit/getHistoryMessagePayload.spec.ts index 3f66e3b508059..126c9b341fac1 100644 --- a/packages/ui-voip/src/ui-kit/getHistoryMessagePayload.spec.ts +++ b/packages/ui-voip/src/ui-kit/getHistoryMessagePayload.spec.ts @@ -177,9 +177,9 @@ describe('getHistoryMessagePayload', () => { }); it('should return correct payload for "not-answered" state', () => { - const result = getHistoryMessagePayload('not-answered', undefined, 'callid'); + const result = getHistoryMessagePayload('not-answered', undefined, 'callid', 'call was not answered'); expect(result).toEqual({ - msg: '', + msg: 'call was not answered', groupable: false, blocks: [ { diff --git a/packages/ui-voip/src/ui-kit/getHistoryMessagePayload.ts b/packages/ui-voip/src/ui-kit/getHistoryMessagePayload.ts index cf9216f6c4443..5578c9b6f1ef1 100644 --- a/packages/ui-voip/src/ui-kit/getHistoryMessagePayload.ts +++ b/packages/ui-voip/src/ui-kit/getHistoryMessagePayload.ts @@ -76,13 +76,14 @@ export const getHistoryMessagePayload = ( callState: CallHistoryItemState, callDuration: number | undefined, callId?: string, + msg: string = '', ): Pick & { blocks: [InfoCardBlock] } => { const callStateTranslationKey = callStateToTranslationKey(callState); const icon = callStateToIcon(callState); const callDurationFormatted = getFormattedCallDuration(callDuration); return { - msg: '', + msg, groupable: false, blocks: [ {