Skip to content

Commit 0eaeee8

Browse files
feat: send push notifications for voice calls
1 parent 453508e commit 0eaeee8

11 files changed

Lines changed: 186 additions & 65 deletions

File tree

apps/meteor/app/push/server/apn.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import apn from '@parse/node-apn';
2-
import type { IPushToken, RequiredField } from '@rocket.chat/core-typings';
2+
import type { RequiredField } from '@rocket.chat/core-typings';
33
import EJSON from 'ejson';
44

55
import type { PushOptions, PendingPushNotification } from './definition';
@@ -24,7 +24,7 @@ export const sendAPN = ({
2424
}: {
2525
userToken: string;
2626
notification: PendingPushNotification & { topic: string };
27-
_removeToken: (token: IPushToken['token']) => void;
27+
_removeToken: (token: string) => void;
2828
}) => {
2929
if (!apnConnection) {
3030
throw new Error('Apn Connection not initialized.');
@@ -34,7 +34,13 @@ export const sendAPN = ({
3434

3535
const note = new apn.Notification();
3636

37-
note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now.
37+
if (notification.voip) {
38+
note.expiry = Math.floor(Date.now() / 1000) + 60; // Expires in 60 seconds
39+
note.pushType = 'voip';
40+
} else {
41+
note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now.
42+
}
43+
3844
if (notification.badge !== undefined) {
3945
note.badge = notification.badge;
4046
}
@@ -50,10 +56,16 @@ export const sendAPN = ({
5056
// adds category support for iOS8 custom actions as described here:
5157
// https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/
5258
// RemoteNotificationsPG/Chapters/IPhoneOSClientImp.html#//apple_ref/doc/uid/TP40008194-CH103-SW36
53-
note.category = notification.apn?.category;
59+
if (notification.apn?.category) {
60+
note.category = notification.apn.category;
61+
}
5462

55-
note.body = notification.text;
56-
note.title = notification.title;
63+
if (notification.text) {
64+
note.body = notification.text;
65+
}
66+
if (notification.title) {
67+
note.title = notification.title;
68+
}
5769

5870
if (notification.notId != null) {
5971
note.threadId = String(notification.notId);
@@ -62,7 +74,9 @@ export const sendAPN = ({
6274
// Allow the user to set payload data
6375
note.payload = notification.payload ? { ejson: EJSON.stringify(notification.payload) } : {};
6476

65-
note.payload.messageFrom = notification.from;
77+
if (notification.from) {
78+
note.payload.messageFrom = notification.from;
79+
}
6680
note.priority = priority;
6781

6882
note.topic = notification.topic;
@@ -81,9 +95,7 @@ export const sendAPN = ({
8195
msg: 'Removing APN token',
8296
token: userToken,
8397
});
84-
_removeToken({
85-
apn: userToken,
86-
});
98+
_removeToken(userToken);
8799
}
88100
});
89101
});

apps/meteor/app/push/server/definition.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ export type PushOptions = {
1818
};
1919

2020
export type PendingPushNotification = {
21-
from: string;
22-
title: string;
23-
text: string;
21+
from?: string;
22+
title?: string;
23+
text?: string;
2424
badge?: number;
2525
sound?: string;
2626
notId?: number;
@@ -42,4 +42,5 @@ export type PendingPushNotification = {
4242
priority?: number;
4343

4444
contentAvailable?: 1 | 0;
45+
voip?: boolean;
4546
};

apps/meteor/app/push/server/fcm.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import type { NativeNotificationParameters } from './push';
99
type FCMDataField = Record<string, any>;
1010

1111
type FCMNotificationField = {
12-
title: string;
13-
body: string;
12+
title?: string;
13+
body?: string;
1414
image?: string;
1515
};
1616

@@ -140,13 +140,13 @@ function getFCMMessagesFromPushData(userTokens: string[], notification: PendingP
140140

141141
// then we will create the notification field
142142
const notificationField: FCMNotificationField = {
143-
title: notification.title,
144-
body: notification.text,
143+
...(notification.title && { title: notification.title }),
144+
...(notification.text && { body: notification.text }),
145145
};
146146

147147
// then we will create the message
148148
const message: FCMMessage = {
149-
notification: notificationField,
149+
...(Object.keys(notificationField).length && { notification: notificationField }),
150150
data,
151151
android: {
152152
priority: 'HIGH',
@@ -185,7 +185,7 @@ export const sendFCM = function ({ userTokens, notification, _removeToken, optio
185185

186186
const removeToken = () => {
187187
const { token } = fcmRequest.message;
188-
token && _removeToken({ gcm: token });
188+
token && _removeToken(token);
189189
};
190190

191191
const response = fetchWithRetry(url, removeToken, {

apps/meteor/app/push/server/push.ts

Lines changed: 30 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,9 @@ export const isFCMCredentials = ajv.compile<FCMCredentials>(FCMCredentialsValida
7878
// This type must match the type defined in the push gateway
7979
type GatewayNotification = {
8080
uniqueId: string;
81-
from: string;
82-
title: string;
83-
text: string;
81+
from?: string;
82+
title?: string;
83+
text?: string;
8484
badge?: number;
8585
sound?: string;
8686
notId?: number;
@@ -123,8 +123,7 @@ type GatewayNotification = {
123123
export type NativeNotificationParameters = {
124124
userTokens: string | string[];
125125
notification: PendingPushNotification;
126-
_replaceToken: (currentToken: IPushToken['token'], newToken: IPushToken['token']) => void;
127-
_removeToken: (token: IPushToken['token']) => void;
126+
_removeToken: (token: string) => void;
128127
options: RequiredField<PushOptions, 'gcm'>;
129128
};
130129

@@ -167,12 +166,10 @@ class PushClass {
167166
}
168167
}
169168

170-
private replaceToken(currentToken: IPushToken['token'], newToken: IPushToken['token']): void {
171-
void PushToken.updateMany({ token: currentToken }, { $set: { token: newToken } });
172-
}
173-
174-
private removeToken(token: IPushToken['token']): void {
175-
void PushToken.deleteOne({ token });
169+
private removeToken(token: string): void {
170+
void PushToken.removeAllByTokenString(token).catch((err) => {
171+
logger.error({ msg: 'Failed to remove push token', err });
172+
});
176173
}
177174

178175
private shouldUseGateway(): boolean {
@@ -188,10 +185,13 @@ class PushClass {
188185
logger.debug({ msg: 'send to token', token: app.token });
189186

190187
if ('apn' in app.token && app.token.apn) {
188+
const userToken = notification.voip ? app.voipToken : app.token.apn;
189+
const topic = notification.voip ? `${app.appName}.voip` : app.appName;
190+
191191
countApn.push(app._id);
192192
// Send to APN
193-
if (this.options.apn) {
194-
sendAPN({ userToken: app.token.apn, notification: { topic: app.appName, ...notification }, _removeToken: this.removeToken });
193+
if (this.options.apn && userToken) {
194+
sendAPN({ userToken, notification: { topic, ...notification }, _removeToken: this.removeToken });
195195
}
196196
} else if ('gcm' in app.token && app.token.gcm) {
197197
countGcm.push(app._id);
@@ -210,7 +210,6 @@ class PushClass {
210210
sendFCM({
211211
userTokens: app.token.gcm,
212212
notification,
213-
_replaceToken: this.replaceToken,
214213
_removeToken: this.removeToken,
215214
options: sendGCMOptions as RequiredField<PushOptions, 'gcm'>,
216215
});
@@ -275,16 +274,7 @@ class PushClass {
275274

276275
if (result.status === 406) {
277276
logger.info({ msg: 'removing push token', token });
278-
await PushToken.deleteMany({
279-
$or: [
280-
{
281-
'token.apn': token,
282-
},
283-
{
284-
'token.gcm': token,
285-
},
286-
],
287-
});
277+
this.removeToken(token);
288278
return;
289279
}
290280

@@ -340,8 +330,13 @@ class PushClass {
340330
logger.debug({ msg: 'send to token', token: app.token });
341331

342332
if ('apn' in app.token && app.token.apn) {
343-
countApn.push(app._id);
344-
return this.sendGatewayPush(gateway, 'apn', app.token.apn, { topic: app.appName, ...gatewayNotification });
333+
const token = notification.voip ? app.voipToken : app.token.apn;
334+
const topic = notification.voip ? `${app.appName}.voip` : app.appName;
335+
336+
if (token) {
337+
countApn.push(app._id);
338+
return this.sendGatewayPush(gateway, 'apn', token, { topic, ...gatewayNotification });
339+
}
345340
}
346341

347342
if ('gcm' in app.token && app.token.gcm) {
@@ -373,12 +368,7 @@ class PushClass {
373368
userId: notification.userId,
374369
});
375370

376-
const query = {
377-
userId: notification.userId,
378-
$or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }],
379-
};
380-
381-
const appTokens = PushToken.find(query);
371+
const appTokens = PushToken.findAllTokensByUserId(notification.userId);
382372

383373
for await (const app of appTokens) {
384374
logger.debug({ msg: 'send to token', token: app.token });
@@ -427,9 +417,9 @@ class PushClass {
427417
private _validateDocument(notification: PendingPushNotification): void {
428418
// Check the general notification
429419
check(notification, {
430-
from: String,
431-
title: String,
432-
text: String,
420+
from: Match.Optional(String),
421+
title: Match.Optional(String),
422+
text: Match.Optional(String),
433423
sent: Match.Optional(Boolean),
434424
sending: Match.Optional(Match.Integer),
435425
badge: Match.Optional(Match.Integer),
@@ -448,6 +438,7 @@ class PushClass {
448438
createdAt: Date,
449439
createdBy: Match.OneOf(String, null),
450440
priority: Match.Optional(Match.Integer),
441+
voip: Match.Optional(Boolean),
451442
});
452443

453444
if (!notification.userId) {
@@ -464,16 +455,15 @@ class PushClass {
464455
}
465456

466457
public async send(options: IPushNotificationConfig) {
467-
const notification: PendingPushNotification = {
458+
const notification = {
468459
createdAt: new Date(),
469460
// createdBy is no longer used, but the gateway still expects it
470461
createdBy: '<SERVER>',
471462
sent: false,
472463
sending: 0,
473-
title: truncateString(options.title, PUSH_TITLE_LIMIT),
474-
text: truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT),
475-
476-
...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority'),
464+
...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority', 'voip'),
465+
...(options.title && { title: truncateString(options.title, PUSH_TITLE_LIMIT) }),
466+
...(options.text && { text: truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT) }),
477467

478468
...(this.hasApnOptions(options)
479469
? {

apps/meteor/server/services/media-call/service.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
IInternalMediaCallHistoryItem,
77
CallHistoryItemState,
88
IExternalMediaCallHistoryItem,
9+
MediaCallContact,
910
} from '@rocket.chat/core-typings';
1011
import { Logger } from '@rocket.chat/logger';
1112
import { callServer, type IMediaCallServerSettings } from '@rocket.chat/media-calls';
@@ -15,7 +16,11 @@ import { CallHistory, MediaCalls, Rooms, Users } from '@rocket.chat/models';
1516
import { getHistoryMessagePayload } from '@rocket.chat/ui-voip/dist/ui-kit/getHistoryMessagePayload';
1617

1718
import { sendMessage } from '../../../app/lib/server/functions/sendMessage';
19+
import { metrics } from '../../../app/metrics/server/lib/metrics';
20+
import { Push } from '../../../app/push/server/push';
21+
import PushNotification from '../../../app/push-notifications/server/lib/PushNotification';
1822
import { settings } from '../../../app/settings/server';
23+
import { getUserAvatarURL } from '../../../app/utils/server/getUserAvatarURL';
1924
import { createDirectMessage } from '../../methods/createDirectMessage';
2025

2126
const logger = new Logger('media-call service');
@@ -28,6 +33,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
2833
callServer.emitter.on('signalRequest', ({ toUid, signal }) => this.sendSignal(toUid, signal));
2934
callServer.emitter.on('callUpdated', (params) => api.broadcast('media-call.updated', params));
3035
callServer.emitter.on('historyUpdate', ({ callId }) => setImmediate(() => this.saveCallToHistory(callId)));
36+
callServer.emitter.on('pushNotificationRequest', ({ callId }) => this.sendPushNotification(callId));
3137
this.onEvent('media-call.updated', (params) => callServer.receiveCallUpdate(params));
3238

3339
this.onEvent('watch.settings', async ({ setting }): Promise<void> => {
@@ -71,6 +77,75 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
7177
}
7278
}
7379

80+
private async getActorUser<T extends Pick<IUser, '_id' | 'name' | 'username' | 'freeSwitchExtension'>>(
81+
actor: MediaCallContact,
82+
): Promise<T | null> {
83+
const options = { projection: { name: 1, username: 1 } };
84+
85+
switch (actor.type) {
86+
case 'user':
87+
return Users.findOneById<T>(actor.id, options);
88+
case 'sip':
89+
return Users.findOneByFreeSwitchExtension<T>(actor.id, options);
90+
}
91+
}
92+
93+
private async getActorUserData(actor: MediaCallContact): Promise<{ name: string; avatarUrl?: string }> {
94+
const user = await this.getActorUser(actor);
95+
96+
if (user) {
97+
return {
98+
name: user.name || user.username || user.freeSwitchExtension || '',
99+
...(user.username && { avatarUrl: getUserAvatarURL(user.username) }),
100+
};
101+
}
102+
103+
if (actor.type === 'sip') {
104+
return {
105+
name: actor.displayName || actor.sipExtension || actor.id,
106+
};
107+
}
108+
109+
return {
110+
name: actor.displayName || actor.username || '',
111+
...(actor.username && { avatarUrl: getUserAvatarURL(actor.username) }),
112+
};
113+
}
114+
115+
private async sendPushNotification(callId: IMediaCall['_id']): Promise<void> {
116+
const call = await MediaCalls.findOneById(callId);
117+
if (!call) {
118+
logger.error({ msg: 'Failed to send push notification: Media Call not found', callId });
119+
return;
120+
}
121+
122+
if (call.callee.type !== 'user') {
123+
logger.error({ msg: 'Failed to send push notification: Invalid Callee Type', callId });
124+
return;
125+
}
126+
127+
const { id: userId, username } = call.callee;
128+
const { name: caller, avatarUrl: avatar } = await this.getActorUserData(call.caller);
129+
130+
metrics.notificationsSent.inc({ notification_type: 'mobile' });
131+
await Push.send({
132+
voip: true,
133+
priority: 10,
134+
payload: {
135+
host: Meteor.absoluteUrl(),
136+
hostName: settings.get<string>('Site_Name'),
137+
notificationType: 'voip',
138+
...(avatar && { avatar }),
139+
state: call.state,
140+
callId: call._id,
141+
caller,
142+
username,
143+
},
144+
userId,
145+
notId: PushNotification.getNotificationId(call._id),
146+
});
147+
}
148+
74149
private async saveCallToHistory(callId: IMediaCall['_id']): Promise<void> {
75150
logger.info({ msg: 'saving media call to history', callId });
76151

0 commit comments

Comments
 (0)