Skip to content

Commit 43b8434

Browse files
state change notifications
1 parent 0eaeee8 commit 43b8434

12 files changed

Lines changed: 206 additions & 103 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,5 @@ export type PendingPushNotification = {
4242
priority?: number;
4343

4444
contentAvailable?: 1 | 0;
45-
voip?: boolean;
45+
useVoipToken?: boolean;
4646
};

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

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const _matchToken = Match.OneOf({ apn: String }, { gcm: String });
1818

1919
const PUSH_TITLE_LIMIT = 65;
2020
const PUSH_MESSAGE_BODY_LIMIT = 240;
21+
const PUSH_GATEWAY_MAX_ATTEMPTS = 5;
2122

2223
type FCMCredentials = {
2324
type: string;
@@ -185,8 +186,8 @@ class PushClass {
185186
logger.debug({ msg: 'send to token', token: app.token });
186187

187188
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;
189+
const userToken = notification.useVoipToken ? app.voipToken : app.token.apn;
190+
const topic = notification.useVoipToken ? `${app.appName}.voip` : app.appName;
190191

191192
countApn.push(app._id);
192193
// Send to APN
@@ -254,7 +255,7 @@ class PushClass {
254255
service: 'apn' | 'gcm',
255256
token: string,
256257
notification: Optional<GatewayNotification, 'uniqueId'>,
257-
tries = 0,
258+
retryOptions: { tries: number; maxTries: number } = { tries: 0, maxTries: PUSH_GATEWAY_MAX_ATTEMPTS },
258259
): Promise<void> {
259260
notification.uniqueId = this.options.uniqueId;
260261

@@ -292,15 +293,17 @@ class PushClass {
292293
return;
293294
}
294295

296+
const { tries, maxTries } = retryOptions;
297+
295298
logger.error({ msg: 'Error sending push to gateway', tries, err: response });
296299

297-
if (tries <= 4) {
300+
if (tries < maxTries) {
298301
// [1, 2, 4, 8, 16] minutes (total 31)
299302
const ms = 60000 * Math.pow(2, tries);
300303

301304
logger.log({ msg: 'Retrying push to gateway', tries: tries + 1, in: ms });
302305

303-
setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, tries + 1), ms);
306+
setTimeout(() => this.sendGatewayPush(gateway, service, token, notification, { tries: tries + 1, maxTries }), ms);
304307
}
305308
}
306309

@@ -325,40 +328,47 @@ class PushClass {
325328
}
326329

327330
const gatewayNotification = this.getGatewayNotificationData(notification);
331+
const retryOptions = {
332+
tries: 0,
333+
maxTries: notification.useVoipToken ? 1 : PUSH_GATEWAY_MAX_ATTEMPTS,
334+
};
328335

329336
for (const gateway of this.options.gateways) {
330337
logger.debug({ msg: 'send to token', token: app.token });
331338

332339
if ('apn' in app.token && app.token.apn) {
333-
const token = notification.voip ? app.voipToken : app.token.apn;
334-
const topic = notification.voip ? `${app.appName}.voip` : app.appName;
340+
const token = notification.useVoipToken ? app.voipToken : app.token.apn;
341+
const topic = notification.useVoipToken ? `${app.appName}.voip` : app.appName;
335342

336343
if (token) {
337344
countApn.push(app._id);
338-
return this.sendGatewayPush(gateway, 'apn', token, { topic, ...gatewayNotification });
345+
return this.sendGatewayPush(gateway, 'apn', token, { topic, ...gatewayNotification }, retryOptions);
339346
}
340347
}
341348

342349
if ('gcm' in app.token && app.token.gcm) {
343350
countGcm.push(app._id);
344-
return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, gatewayNotification);
351+
return this.sendGatewayPush(gateway, 'gcm', app.token.gcm, gatewayNotification, retryOptions);
345352
}
346353
}
347354
}
348355

349-
private async sendNotification(notification: PendingPushNotification): Promise<{ apn: string[]; gcm: string[] }> {
356+
private async sendNotification(
357+
notification: PendingPushNotification,
358+
options: { skipTokenId?: IPushToken['_id'] } = {},
359+
): Promise<{ apn: string[]; gcm: string[] }> {
350360
logger.debug({ msg: 'Sending notification', notification });
351361

352362
const countApn: string[] = [];
353363
const countGcm: string[] = [];
354364

355-
if (notification.from !== String(notification.from)) {
365+
if (notification.from && notification.from !== String(notification.from)) {
356366
throw new Error('Push.send: option "from" not a string');
357367
}
358-
if (notification.title !== String(notification.title)) {
368+
if (notification.title && notification.title !== String(notification.title)) {
359369
throw new Error('Push.send: option "title" not a string');
360370
}
361-
if (notification.text !== String(notification.text)) {
371+
if (notification.text && notification.text !== String(notification.text)) {
362372
throw new Error('Push.send: option "text" not a string');
363373
}
364374

@@ -368,7 +378,9 @@ class PushClass {
368378
userId: notification.userId,
369379
});
370380

371-
const appTokens = PushToken.findAllTokensByUserId(notification.userId);
381+
const appTokens = options.skipTokenId
382+
? PushToken.findTokensByUserIdExceptId(notification.userId, options.skipTokenId)
383+
: PushToken.findAllTokensByUserId(notification.userId);
372384

373385
for await (const app of appTokens) {
374386
logger.debug({ msg: 'send to token', token: app.token });
@@ -438,7 +450,7 @@ class PushClass {
438450
createdAt: Date,
439451
createdBy: Match.OneOf(String, null),
440452
priority: Match.Optional(Match.Integer),
441-
voip: Match.Optional(Boolean),
453+
useVoipToken: Match.Optional(Boolean),
442454
});
443455

444456
if (!notification.userId) {
@@ -455,13 +467,13 @@ class PushClass {
455467
}
456468

457469
public async send(options: IPushNotificationConfig) {
458-
const notification = {
470+
const notification: PendingPushNotification = {
459471
createdAt: new Date(),
460472
// createdBy is no longer used, but the gateway still expects it
461473
createdBy: '<SERVER>',
462474
sent: false,
463475
sending: 0,
464-
...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority', 'voip'),
476+
...pick(options, 'from', 'userId', 'payload', 'badge', 'sound', 'notId', 'priority', 'useVoipToken'),
465477
...(options.title && { title: truncateString(options.title, PUSH_TITLE_LIMIT) }),
466478
...(options.text && { text: truncateString(options.text, PUSH_MESSAGE_BODY_LIMIT) }),
467479

@@ -485,7 +497,7 @@ class PushClass {
485497
this._validateDocument(notification);
486498

487499
try {
488-
await this.sendNotification(notification);
500+
await this.sendNotification(notification, pick(options, 'skipTokenId'));
489501
} catch (error: any) {
490502
logger.debug({
491503
msg: 'Could not send notification to user',
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { Logger } from '@rocket.chat/logger';
2+
3+
export const logger = new Logger('media-call service');
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { IMediaCall } from '@rocket.chat/core-typings';
2+
3+
export type VoipPushNotificationType = 'incoming_call' | 'remoteEnded' | 'answeredElsewhere' | 'declinedElsewhere' | 'unanswered';
4+
5+
export function getPushNotificationType(call: IMediaCall): VoipPushNotificationType {
6+
if (call.acceptedAt) {
7+
return 'answeredElsewhere';
8+
}
9+
10+
if (call.endedBy?.id === call.callee.id || call.hangupReason === 'rejected') {
11+
return 'declinedElsewhere';
12+
}
13+
14+
if (call.endedBy?.id === call.caller.id) {
15+
return 'remoteEnded';
16+
}
17+
18+
if (call.ended) {
19+
return 'unanswered';
20+
}
21+
22+
return 'incoming_call';
23+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import type { IMediaCall, IUser, MediaCallContact, MediaCallActorType } from '@rocket.chat/core-typings';
2+
import type { VoipPushNotificationEventType } from '@rocket.chat/media-calls';
3+
import { MediaCalls, Users } from '@rocket.chat/models';
4+
5+
import { getPushNotificationType } from './getPushNotificationType';
6+
import { metrics } from '../../../../app/metrics/server/lib/metrics';
7+
import { Push } from '../../../../app/push/server/push';
8+
import PushNotification from '../../../../app/push-notifications/server/lib/PushNotification';
9+
import { settings } from '../../../../app/settings/server';
10+
import { getUserAvatarURL } from '../../../../app/utils/server/getUserAvatarURL';
11+
import { logger } from '../logger';
12+
13+
async function getActorUser<T extends Pick<IUser, '_id' | 'name' | 'username' | 'freeSwitchExtension'>>(
14+
actor: MediaCallContact,
15+
): Promise<T | null> {
16+
const options = { projection: { name: 1, username: 1 } };
17+
18+
switch (actor.type) {
19+
case 'user':
20+
return Users.findOneById<T>(actor.id, options);
21+
case 'sip':
22+
return Users.findOneByFreeSwitchExtension<T>(actor.id, options);
23+
}
24+
}
25+
26+
async function getActorUserData(
27+
actor: MediaCallContact,
28+
): Promise<{ type: MediaCallActorType; id: string; name: string; avatarUrl?: string }> {
29+
const actorUsername = actor.type === 'user' ? actor.username : undefined;
30+
const actorExtension = actor.sipExtension || (actor.type === 'sip' ? actor.id : undefined);
31+
32+
const data = {
33+
type: actor.type,
34+
id: actor.id,
35+
name: actor.displayName || actorUsername || actorExtension || '',
36+
};
37+
38+
const user = await getActorUser(actor);
39+
40+
if (user) {
41+
const username = user.username || actorUsername;
42+
43+
return {
44+
...data,
45+
name: user.name || user.username || user.freeSwitchExtension || data.name,
46+
...(username && { avatarUrl: getUserAvatarURL(username) }),
47+
};
48+
}
49+
50+
return {
51+
...data,
52+
...(actorUsername && { avatarUrl: getUserAvatarURL(actorUsername) }),
53+
};
54+
}
55+
56+
export async function sendVoipPushNotification(callId: IMediaCall['_id'], event: VoipPushNotificationEventType): Promise<void> {
57+
const call = await MediaCalls.findOneById(callId);
58+
if (!call) {
59+
logger.error({ msg: 'Failed to send push notification: Media Call not found', callId });
60+
return;
61+
}
62+
63+
if (call.callee.type !== 'user') {
64+
logger.error({ msg: 'Failed to send push notification: Invalid Callee Type', callId });
65+
return;
66+
}
67+
68+
// If the call was accepted, we don't need to notify when it ends
69+
if (call.acceptedAt && event !== 'answer') {
70+
return;
71+
}
72+
73+
const type = getPushNotificationType(call);
74+
// If the state changed before we had a chance to send the incoming call, skip it altogether
75+
if (event === 'new' && type !== 'incoming_call') {
76+
return;
77+
}
78+
if (type === 'incoming_call' && event !== 'new') {
79+
return;
80+
}
81+
82+
const { id: userId, username } = call.callee;
83+
const caller = await getActorUserData(call.caller);
84+
85+
metrics.notificationsSent.inc({ notification_type: 'mobile' });
86+
await Push.send({
87+
useVoipToken: type === 'incoming_call',
88+
priority: 10,
89+
payload: {
90+
host: Meteor.absoluteUrl(),
91+
hostName: settings.get<string>('Site_Name'),
92+
notificationType: 'voip',
93+
type,
94+
callId: call._id,
95+
caller,
96+
username,
97+
},
98+
userId,
99+
notId: PushNotification.getNotificationId(call._id),
100+
// We should not send state change notifications to the device where the call was accepted/rejected
101+
...(call.callee.contractId && { skipTokenId: call.callee.contractId }),
102+
});
103+
}

0 commit comments

Comments
 (0)