Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NEW] Audio and Video calling in Livechat #23004

Merged
merged 19 commits into from
Nov 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/livechat/lib/messageTypes.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import formatDistance from 'date-fns/formatDistance';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import moment from 'moment';

import { MessageTypes } from '../../ui-utils';

Expand Down Expand Up @@ -81,6 +83,22 @@ MessageTypes.registerType({
message: 'New_videocall_request',
});

MessageTypes.registerType({
id: 'livechat_webrtc_video_call',
render(message) {
if (message.msg === 'ended' && message.webRtcCallEndTs && message.ts) {
return TAPi18n.__('WebRTC_call_ended_message', {
callDuration: formatDistance(new Date(message.webRtcCallEndTs), new Date(message.ts)),
endTime: moment(message.webRtcCallEndTs).format('h:mm A'),
});
}
if (message.msg === 'declined' && message.webRtcCallEndTs) {
return TAPi18n.__('WebRTC_call_declined_message');
}
return message.msg;
},
});

MessageTypes.registerType({
id: 'omnichannel_placed_chat_on_hold',
system: true,
Expand Down
18 changes: 13 additions & 5 deletions app/livechat/server/api/lib/livechat.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Meteor } from 'meteor/meteor';
import { Random } from 'meteor/random';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';

import { LivechatRooms, LivechatVisitors, LivechatDepartment, LivechatTrigger } from '../../../../models/server';
import { EmojiCustom } from '../../../../models/server/raw';
Expand Down Expand Up @@ -56,6 +57,7 @@ export function findOpenRoom(token, departmentId) {
departmentId: 1,
servedBy: 1,
open: 1,
callStatus: 1,
},
};

Expand Down Expand Up @@ -101,7 +103,7 @@ export async function settings() {
nameFieldRegistrationForm: initSettings.Livechat_name_field_registration_form,
emailFieldRegistrationForm: initSettings.Livechat_email_field_registration_form,
displayOfflineForm: initSettings.Livechat_display_offline_form,
videoCall: initSettings.Livechat_videocall_enabled === true && initSettings.Jitsi_Enabled === true,
videoCall: initSettings.Omnichannel_call_provider === 'Jitsi' && initSettings.Jitsi_Enabled === true,
fileUpload: initSettings.Livechat_fileupload_enabled && initSettings.FileUpload_Enabled,
language: initSettings.Language,
transcript: initSettings.Livechat_enable_transcript,
Expand All @@ -117,10 +119,16 @@ export async function settings() {
color: initSettings.Livechat_title_color,
offlineTitle: initSettings.Livechat_offline_title,
offlineColor: initSettings.Livechat_offline_title_color,
actionLinks: [
{ icon: 'icon-videocam', i18nLabel: 'Accept', method_id: 'createLivechatCall', params: '' },
{ icon: 'icon-cancel', i18nLabel: 'Decline', method_id: 'denyLivechatCall', params: '' },
],
actionLinks: {
webrtc: [
{ actionLinksAlignment: 'flex-start', i18nLabel: 'Join_call', label: TAPi18n.__('Join_call'), method_id: 'joinLivechatWebRTCCall' },
{ i18nLabel: 'End_call', label: TAPi18n.__('End_call'), method_id: 'endLivechatWebRTCCall', danger: true },
],
jitsi: [
{ icon: 'icon-videocam', i18nLabel: 'Accept', method_id: 'createLivechatCall' },
{ icon: 'icon-cancel', i18nLabel: 'Decline', method_id: 'denyLivechatCall' },
],
},
},
messages: {
offlineMessage: initSettings.Livechat_offline_message,
Expand Down
110 changes: 106 additions & 4 deletions app/livechat/server/api/v1/videoCall.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import { Random } from 'meteor/random';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';

import { Messages } from '../../../../models';
import { settings as rcSettings } from '../../../../settings';
import { Messages, Rooms } from '../../../../models';
import { settings as rcSettings } from '../../../../settings/server';
import { API } from '../../../../api/server';
import { findGuest, getRoom, settings } from '../lib/livechat';
import { OmnichannelSourceType } from '../../../../../definition/IRoom';
import { hasPermission, canSendMessage } from '../../../../authorization';
import { Livechat } from '../../lib/Livechat';

API.v1.addRoute('livechat/video.call/:token', {
get() {
Expand Down Expand Up @@ -36,12 +39,12 @@ API.v1.addRoute('livechat/video.call/:token', {
};
const { room } = getRoom({ guest, rid, roomInfo });
const config = Promise.await(settings());
if (!config.theme || !config.theme.actionLinks) {
if (!config.theme || !config.theme.actionLinks || !config.theme.actionLinks.jitsi) {
throw new Meteor.Error('invalid-livechat-config');
}

Messages.createWithTypeRoomIdMessageAndUser('livechat_video_call', room._id, '', guest, {
actionLinks: config.theme.actionLinks,
actionLinks: config.theme.actionLinks.jitsi,
});
let rname;
if (rcSettings.get('Jitsi_URL_Room_Hash')) {
Expand All @@ -63,3 +66,102 @@ API.v1.addRoute('livechat/video.call/:token', {
}
},
});

API.v1.addRoute('livechat/webrtc.call', { authRequired: true }, {
get() {
try {
check(this.queryParams, {
rid: Match.Maybe(String),
});

if (!hasPermission(this.userId, 'view-l-room')) {
return API.v1.unauthorized();
}

const room = canSendMessage(this.queryParams.rid, {
uid: this.userId,
username: this.user.username,
type: this.user.type,
});
if (!room) {
throw new Meteor.Error('invalid-room');
}

const webrtcCallingAllowed = (rcSettings.get('WebRTC_Enabled') === true) && (rcSettings.get('Omnichannel_call_provider') === 'WebRTC');
if (!webrtcCallingAllowed) {
throw new Meteor.Error('webRTC calling not enabled');
}

const config = Promise.await(settings());
if (!config.theme || !config.theme.actionLinks || !config.theme.actionLinks.webrtc) {
throw new Meteor.Error('invalid-livechat-config');
}

let { callStatus } = room;

if (!callStatus || callStatus === 'ended' || callStatus === 'declined') {
callStatus = 'ringing';
Promise.await(Rooms.setCallStatusAndCallStartTime(room._id, callStatus));
Promise.await(Messages.createWithTypeRoomIdMessageAndUser(
'livechat_webrtc_video_call',
room._id,
TAPi18n.__('Join_my_room_to_start_the_video_call'),
this.user,
{
actionLinks: config.theme.actionLinks.webrtc,
},
));
}
const videoCall = {
rid: room._id,
provider: 'webrtc',
callStatus,
};
return API.v1.success({ videoCall });
} catch (e) {
return API.v1.failure(e);
}
},
});

API.v1.addRoute('livechat/webrtc.call/:callId', { authRequired: true }, {
put() {
try {
check(this.urlParams, {
callId: String,
});

check(this.bodyParams, {
rid: Match.Maybe(String),
status: Match.Maybe(String),
});

const { callId } = this.urlParams;
const { rid, status } = this.bodyParams;

if (!hasPermission(this.userId, 'view-l-room')) {
return API.v1.unauthorized();
}

const room = canSendMessage(rid, {
uid: this.userId,
username: this.user.username,
type: this.user.type,
});
if (!room) {
throw new Meteor.Error('invalid-room');
}

const call = Promise.await(Messages.findOneById(callId));
if (!call || call.t !== 'livechat_webrtc_video_call') {
throw new Meteor.Error('invalid-callId');
}

Livechat.updateCallStatus(callId, rid, status, this.user);

return API.v1.success({ status });
} catch (e) {
return API.v1.failure(e);
}
},
});
24 changes: 24 additions & 0 deletions app/livechat/server/api/v1/visitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,30 @@ API.v1.addRoute('livechat/visitor/:token/room', { authRequired: true }, {
},
});

API.v1.addRoute('livechat/visitor.callStatus', {
post() {
try {
check(this.bodyParams, {
token: String,
callStatus: String,
rid: String,
callId: String,
});

const { token, callStatus, rid, callId } = this.bodyParams;
const guest = findGuest(token);
if (!guest) {
throw new Meteor.Error('invalid-token');
}
const status = callStatus;
Livechat.updateCallStatus(callId, rid, status, guest);
return API.v1.success({ token, callStatus });
} catch (e) {
return API.v1.failure(e);
}
},
});

API.v1.addRoute('livechat/visitor.status', {
post() {
try {
Expand Down
25 changes: 15 additions & 10 deletions app/livechat/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,16 +375,6 @@ Meteor.startup(function() {
enableQuery: omnichannelEnabledQuery,
});

this.add('Livechat_videocall_enabled', false, {
type: 'boolean',
group: 'Omnichannel',
section: 'Livechat',
public: true,
i18nLabel: 'Videocall_enabled',
i18nDescription: 'Beta_feature_Depends_on_Video_Conference_to_be_enabled',
enableQuery: [{ _id: 'Jitsi_Enabled', value: true }, omnichannelEnabledQuery],
});

this.add('Livechat_fileupload_enabled', true, {
type: 'boolean',
group: 'Omnichannel',
Expand Down Expand Up @@ -616,5 +606,20 @@ Meteor.startup(function() {
i18nDescription: 'Time_in_seconds',
enableQuery: omnichannelEnabledQuery,
});

this.add('Omnichannel_call_provider', 'none', {
type: 'select',
public: true,
group: 'Omnichannel',
section: 'Video_and_Audio_Call',
values: [
{ key: 'none', i18nLabel: 'None' },
{ key: 'Jitsi', i18nLabel: 'Jitsi' },
{ key: 'WebRTC', i18nLabel: 'WebRTC' },
],
i18nDescription: 'Feature_depends_on_selected_call_provider_to_be_enabled_from_administration_settings',
i18nLabel: 'Call_provider',
enableQuery: omnichannelEnabledQuery,
});
});
});
8 changes: 7 additions & 1 deletion app/livechat/server/lib/Livechat.js
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ export const Livechat = {
'Livechat_offline_success_message',
'Livechat_offline_form_unavailable',
'Livechat_display_offline_form',
'Livechat_videocall_enabled',
'Omnichannel_call_provider',
'Jitsi_Enabled',
'Language',
'Livechat_enable_transcript',
Expand Down Expand Up @@ -1278,6 +1278,12 @@ export const Livechat = {
};
LivechatVisitors.updateById(contactId, updateUser);
},
updateCallStatus(callId, rid, status, user) {
Rooms.setCallStatus(rid, status);
if (status === 'ended' || status === 'declined') {
return updateMessage({ _id: callId, msg: status, actionLinks: [], webRtcCallEndTs: new Date() }, user);
}
},
};

settings.watch('Livechat_history_monitor_type', (value) => {
Expand Down
2 changes: 1 addition & 1 deletion app/livechat/server/methods/getInitialData.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Meteor.methods({
info.offlineUnavailableMessage = initSettings.Livechat_offline_form_unavailable;
info.displayOfflineForm = initSettings.Livechat_display_offline_form;
info.language = initSettings.Language;
info.videoCall = initSettings.Livechat_videocall_enabled === true && initSettings.Jitsi_Enabled === true;
info.videoCall = initSettings.Omnichannel_call_provider === 'Jitsi' && initSettings.Jitsi_Enabled === true;
info.fileUpload = initSettings.Livechat_fileupload_enabled && initSettings.FileUpload_Enabled;
info.transcript = initSettings.Livechat_enable_transcript;
info.transcriptMessage = initSettings.Livechat_transcript_message;
Expand Down
29 changes: 29 additions & 0 deletions app/models/server/models/Rooms.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,35 @@ export class Rooms extends Base {
return this.update(query, update);
}

setCallStatus(_id, status) {
const query = {
_id,
};

const update = {
$set: {
callStatus: status,
},
};

return this.update(query, update);
}

setCallStatusAndCallStartTime(_id, status) {
const query = {
_id,
};

const update = {
$set: {
callStatus: status,
webRtcCallStartTime: new Date(),
},
};

return this.update(query, update);
}

findByTokenpass(tokens) {
const query = {
'tokenpass.tokens.token': {
Expand Down
13 changes: 12 additions & 1 deletion app/models/server/raw/Subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,18 @@ export class SubscriptionsRaw extends BaseRaw<T> {
return this.find(query, options);
}

countByRoomIdAndUserId(rid: string, uid: string): Promise<number> {
findByLivechatRoomIdAndNotUserId(roomId: string, userId: string, options: FindOneOptions<T> = {}): Cursor<T> {
const query = {
rid: roomId,
'servedBy._id': {
$ne: userId,
},
};

return this.find(query, options);
}

countByRoomIdAndUserId(rid: string, uid: string | undefined): Promise<number> {
const query = {
rid,
'u._id': uid,
Expand Down
10 changes: 5 additions & 5 deletions app/notifications/client/lib/Notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ class Notifications {
return this.streamRoom.on(`${ room }/${ eventName }`, callback);
}

async onUser(eventName, callback) {
await this.streamUser.on(`${ Meteor.userId() }/${ eventName }`, callback);
return () => this.unUser(eventName, callback);
async onUser(eventName, callback, visitorId = null) {
await this.streamUser.on(`${ Meteor.userId() || visitorId }/${ eventName }`, callback);
return () => this.unUser(eventName, callback, visitorId);
}

unAll(callback) {
Expand All @@ -95,8 +95,8 @@ class Notifications {
return this.streamRoom.removeListener(`${ room }/${ eventName }`, callback);
}

unUser(eventName, callback) {
return this.streamUser.removeListener(`${ Meteor.userId() }/${ eventName }`, callback);
unUser(eventName, callback, visitorId = null) {
return this.streamUser.removeListener(`${ Meteor.userId() || visitorId }/${ eventName }`, callback);
}
}

Expand Down
1 change: 1 addition & 0 deletions app/utils/client/lib/RestApiClient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export declare const APIClient: {
delete<P, R = any>(endpoint: string, params?: Serialized<P>): Promise<Serialized<R>>;
get<P, R = any>(endpoint: string, params?: Serialized<P>): Promise<Serialized<R>>;
post<P, B, R = any>(endpoint: string, params?: Serialized<P>, body?: B): Promise<Serialized<R>>;
put<P, B, R = any>(endpoint: string, params?: Serialized<P>, body?: B): Promise<Serialized<R>>;
upload<P, B, R = any>(
endpoint: string,
params?: Serialized<P>,
Expand Down
Loading