vertinho
Version:
Library to make conference apps and softphones through WebSockets with FreeSWITCH mod_verto.
512 lines (440 loc) • 15.1 kB
JavaScript
/**
* _ _ _
* | | (_) | |
* __ _____ _ __| |_ _ _ __ | |__ ___
* \ \ / / _ \ '__| __| | '_ \| '_ \ / _ \
* \ V / __/ | | |_| | | | | | | | (_) |
* \_/ \___|_| \__|_|_| |_|_| |_|\___/
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
* And see https://github.com/Mazuh/vertinho for the full license details.
*/
import Call from './Call';
import ConferenceManager from '../conference/ConferenceManager';
import ConferenceLiveArray from '../conference/ConferenceLiveArray';
import { printError, printWarning } from '../common/utils';
import { generateGUID, ENUM } from './utils';
export default class VertinhoClient {
constructor(params, vertoCallbacks = {}, conferenceCallbacks = {}) {
this.params = { ...params };
const defaultCallback = x => x;
this.callbacks = {
onClientReady: defaultCallback,
onConferenceReady: defaultCallback,
onConferenceDisabled: defaultCallback,
onInfo: defaultCallback,
onDisplay: defaultCallback,
onCallStateChange: defaultCallback,
onPrivateEvent: defaultCallback,
...vertoCallbacks,
};
this.conferenceCallbacks = {
onReady: defaultCallback,
onDestroyed: defaultCallback,
onBootstrappedMembers: defaultCallback,
onAddedMember: defaultCallback,
onModifiedMember: defaultCallback,
onRemovedMember: defaultCallback,
onChatMessage: defaultCallback,
onInfo: defaultCallback,
onModeration: defaultCallback,
...conferenceCallbacks,
};
this.webSocket = null;
this.webSocketCallbacks = {};
this.retryingTimer = null;
this.currentWebSocketRequestId = 0;
this.options = {};
this.calls = {};
this.conference = null;
this.connect();
}
connect() {
this.options = {
webSocket: {
login: '',
password: '',
url: '',
},
videoParams: {},
audioParams: {},
loginParams: {},
deviceParams: {},
userVariables: {},
iceServers: false,
ringSleep: 6000,
sessid: null,
onmessage: event => this.handleMessage(event.eventData),
onWebSocketLoginSuccess: () => {},
onWebSocketLoginError: error => printError('Error reported by WebSocket login', error),
onPeerStreaming: () => {},
onPeerStreamingError: () => {},
...this.params,
...this.callbacks,
};
if (!this.options.deviceParams.useMic) {
this.options.deviceParams.useMic = 'any';
}
if (!this.options.deviceParams.useSpeak) {
this.options.deviceParams.useSpeak = 'any';
}
if (!this.options.blockSessionRecovery) {
if (this.options.sessid) {
this.sessid = this.options.sessid;
} else {
this.sessid = localStorage.getItem('verto_session_uuid') || generateGUID();
localStorage.setItem('verto_session_uuid', this.sessid);
}
} else {
this.sessid = generateGUID();
}
this.calls = {};
this.callbacks = this.callbacks || {};
this.webSocketSubscriptions = {};
this.connectSocket();
}
connectSocket() {
if (this.retryingTimer) {
clearTimeout(this.retryingTimer);
}
if (this.socketReady()) {
printWarning('Tried to connect to socket but already had a ready one');
return;
}
this.authing = false;
if (this.webSocket) {
delete this.webSocket;
}
this.webSocket = new WebSocket(this.options.webSocket.url);
this.webSocket.onmessage = this.onWebSocketMessage.bind(this);
this.webSocket.onclose = () => {
printWarning('WebSocket closed, attempting to connect again in 1s.');
this.retryingTimer = setTimeout(this.connectSocket.bind(this), 1000);
};
this.webSocket.onopen = () => {
if (this.retryingTimer) {
printWarning('Successfully WebSocket attempt to reconnect.');
clearTimeout(this.retryingTimer);
}
this.publish('login', {});
};
}
socketReady() {
if (this.webSocket === null || this.webSocket.readyState > 1) {
return false;
}
return true;
}
purge() {
Object.keys(this.calls).forEach((callId) => {
this.calls[callId].setState(ENUM.state.purge);
});
this.webSocketSubscriptions = {};
}
publish(method, params = {}, onSuccess = x => x, onError = x => x) {
this.currentWebSocketRequestId += 1;
const request = {
jsonrpc: '2.0',
method,
params: { sessid: this.sessid, ...params },
id: this.currentWebSocketRequestId,
};
const requestStringified = JSON.stringify(request);
if ('id' in request && onSuccess !== undefined) {
this.webSocketCallbacks[request.id] = {
requestStringified,
request,
onSuccess,
onError,
};
}
this.webSocket.send(requestStringified);
}
handleJSONRPCMessage(message) {
if (message.result) {
const { onSuccess } = this.webSocketCallbacks[message.id];
delete this.webSocketCallbacks[message.id];
onSuccess(message.result, this);
return;
}
if (!message.error) {
return;
}
if (!this.authing && parseInt(message.error.code, 10) === -32000) {
this.authing = true;
this.publish('login', {
login: this.options.webSocket.login,
passwd: this.options.webSocket.password,
loginParams: this.options.loginParams,
userVariables: this.options.userVariables,
},
() => {
this.authing = false;
delete this.webSocketCallbacks[message.id];
this.options.onWebSocketLoginSuccess();
},
() => {
delete this.webSocketCallbacks[message.id];
this.options.onWebSocketLoginError(message.error);
});
return;
}
const { onError } = this.webSocketCallbacks[message.id];
delete this.webSocketCallbacks[message.id];
onError(message.error, this);
}
onWebSocketMessage(event) {
const message = JSON.parse(event.data);
if (message && message.jsonrpc === '2.0' && this.webSocketCallbacks[message.id]) {
this.handleJSONRPCMessage(message);
return;
}
if (typeof this.options.onmessage !== 'function') {
return;
}
const fixedEvent = { ...event, eventData: message || {} };
const reply = this.options.onmessage(fixedEvent);
if (typeof reply !== 'object' || !fixedEvent.eventData.id || !this.webSocket) {
return;
}
this.webSocket.send(JSON.stringify({
jsonrpc: '2.0',
id: fixedEvent.eventData.id,
result: reply,
}));
}
handleMessage(data) {
if (!data || !data.method || !data.params) {
printError('Invalid WebSocket message', data);
return;
}
if (data.params.eventType === 'channelPvtData') {
this.handleChannelPrivateDataMessage(data);
} else if (data.params.callID) {
this.handleMessageForCall(data);
} else {
this.handleMessageForClient(data);
}
}
handleChannelPrivateDataMessage(data) {
const { params: event } = data;
const existingConference = this.conference && { ...this.conference };
if (event.pvtData.action === 'conference-liveArray-join') {
if (existingConference) {
printWarning('Ignoring doubled private event of live array join', event);
return;
}
const conference = {
creationEvent: event,
privateEventChannel: event.eventChannel,
memberId: event.pvtData.conferenceMemberID,
role: event.pvtData.role,
manager: new ConferenceManager(this, {
chat: {
channel: event.pvtData.chatChannel,
handler: this.conferenceCallbacks.onChatMessage,
},
info: {
channel: event.pvtData.infoChannel,
handler: this.conferenceCallbacks.onInfo,
},
moderation: event.pvtData.modChannel ? null : {
channel: event.pvtData.modChannel,
handler: this.conferenceCallbacks.onModeration,
},
}),
liveArray: new ConferenceLiveArray(
this,
event.pvtData.laChannel,
event.pvtData.laName,
{
onBootstrappedMembers: this.conferenceCallbacks.onBootstrappedMembers,
onAddedMember: this.conferenceCallbacks.onAddedMember,
onModifiedMember: this.conferenceCallbacks.onModifiedMember,
onRemovedMember: this.conferenceCallbacks.onRemovedMember,
},
),
};
this.conference = conference;
this.conferenceCallbacks.onReady(conference);
} else if (event.pvtData.action === 'conference-liveArray-part') {
if (!existingConference) {
printWarning('Ignoring event of live array part without conference instance', event);
return;
}
existingConference.manager.destroy();
existingConference.liveArray.destroy();
this.conference = null;
this.conferenceCallbacks.onDestroyed(existingConference);
} else {
printWarning('Not implemented private data message', data);
}
}
handleMessageForClient(data) {
const channel = data.params.eventChannel;
const subscription = channel && this.webSocketSubscriptions[channel];
switch (data.method) {
case 'verto.punt':
this.destroy();
break;
case 'verto.event':
if (!subscription && channel === this.sessid) {
this.callbacks.onPrivateEvent(data.params);
} else if (!subscription && channel && this.calls[channel]) {
this.callbacks.onPrivateEvent(data.params);
} else if (!subscription) {
printWarning('Ignoring event for unsubscribed channel', channel, data.params);
} else if (!subscription || !subscription.ready) {
printError('Ignoring event for a not ready channel', channel, data.params);
} else if (subscription.handler) {
subscription.handler(data.params, subscription.userData);
} else if (this.callbacks.onEvent) {
this.callbacks.onEvent(this, data.params, subscription.userData);
} else {
printWarning('Ignoring event without callback', channel, data.params);
}
break;
case 'verto.info':
this.callbacks.onInfo(data.params);
break;
case 'verto.clientReady':
this.callbacks.onClientReady(data.params);
break;
default:
printWarning('Ignoring invalid method with no call id', data.method);
break;
}
}
handleMessageForCall(data) {
const existingCall = this.calls[data.params.callID];
if (existingCall) {
switch (data.method) {
case 'verto.bye':
existingCall.hangup(data.params);
break;
case 'verto.answer':
existingCall.handleAnswer(data.params.sdp);
break;
case 'verto.media':
existingCall.handleMedia(data.params.sdp);
break;
case 'verto.display':
existingCall.handleDisplay(data.params['display_name'], data.params['display_number']);
break;
case 'verto.info':
existingCall.handleInfo(data.params);
break;
default:
printWarning('Ignoring existing call event with invalid method', data.method);
break;
}
} else if (data.method === 'verto.attach' || data.method === 'verto.invite') {
const newCall = new Call(ENUM.direction.inbound, this, {
...data.params,
attach: true,
useVideo: data.params.sdp && data.params.sdp.indexOf('m=video') > 0,
useStereo: data.params.sdp && data.params.sdp.indexOf('stereo=1') > 0,
});
if (data.method === 'verto.attach') {
newCall.setState(ENUM.state.recovering);
}
} else {
printWarning('Ignoring call event with invalid method', data.method);
}
}
processReply(method, { subscribedChannels, unauthorizedChannels }) {
if (method !== 'verto.subscribe') {
return;
}
Object.keys(subscribedChannels || {}).forEach((channelKey) => {
const channel = subscribedChannels[channelKey];
this.setReadySubscription(channel);
});
Object.keys(unauthorizedChannels || {}).forEach((channelKey) => {
const channel = unauthorizedChannels[channelKey];
printError('Unauthorized', channel);
this.setDroppedSubscription(channel);
});
}
setDroppedSubscription(channel) {
delete this.webSocketSubscriptions[channel];
}
setReadySubscription(channel) {
const subscription = this.webSocketSubscriptions[channel];
if (subscription) {
subscription.ready = true;
}
}
broadcastMethod(method, params) {
const reply = event => this.processReply(method, event);
this.publish(method, params, reply, reply);
}
broadcast(eventChannel, data) {
this.broadcastMethod('verto.broadcast', { eventChannel, data });
}
subscribe(eventChannel, params = {}) {
const eventSubscription = {
eventChannel,
handler: params.handler,
userData: params.userData,
ready: false,
};
if (this.webSocketSubscriptions[eventChannel]) {
printWarning('Overwriting an already subscribed channel', eventChannel);
}
this.webSocketSubscriptions[eventChannel] = eventSubscription;
this.broadcastMethod('verto.subscribe', { eventChannel });
return eventSubscription;
}
unsubscribe(eventChannel) {
delete this.webSocketSubscriptions[eventChannel];
this.broadcastMethod('verto.unsubscribe', { eventChannel });
}
makeVideoCall({ callerName, ...params }, mediaHandlers = {}) {
if (!callerName) {
printError('No `callerName` parameter on making video call.');
}
return this.makeCall({ callerName, useVideo: true, ...params }, mediaHandlers);
}
makeCall({ to, from, ...otherParams }, mediaHandlers = {}) {
if (!to || !from) {
printError('No `to` or `from` parameters on making call.');
return null;
}
const { callerName = 'Vertinho', ...params } = otherParams;
params['destination_number'] = to;
params['caller_id_number'] = from;
params['caller_id_name'] = callerName;
if (!this.socketReady()) {
printError('Socket not ready.');
return null;
}
const call = new Call(ENUM.direction.outbound, this, params, mediaHandlers);
call.rtc.inviteRemotePeerConnection();
return call;
}
destroy() {
if (this.socketReady()) {
this.webSocket.close();
this.purge();
} else {
printError('Tried to close a not ready socket while destroying.');
}
}
hangup(callId) {
if (callId) {
const call = this.calls[callId];
if (call) {
call.hangup();
} else {
printError('Error on hanging up call', callId);
}
return;
}
Object.keys(this.calls).forEach((id) => {
this.calls[id].hangup();
});
}
}