最近有一个客户的呼叫中心项目,客户提出了一个强制性需求,要求坐席使用PC+Phone的方式来接听电话,而且最重要的是PC不能安装任何软件或者浏览器插件,研究了半天,似乎只有华山一条路了,那就是使用基于现代浏览器的webrtc音频通信技术了,可喜的是,Freeswtich作为语音服务器,已经天然支持webrtc技术了,而且成熟度很高,基本只要做些配置就好。
对于那些不熟悉webrtc技术的小伙伴,先简单的介绍下,引自百度:
“WebRTC (Web Real-Time Communications) 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。WebRTC 包含的这些标准使用户在无需安装任何插件或者第三方的软件的情况下,创建点对点(Peer-to-Peer)的数据分享和电话会议成为可能。”
典型的webrtc技术栈如下图所示:
由于webrtc是基于浏览器实现的,所以开发使用天然的会会使用javascripts语音,感谢开源项目的贡献,已经多个基于webrtc的js封装库可用了,比较有名的有jssip,sip.js等,这次我们选用了sip.js作为客户端的开发基础库,来实现VoIP的通话控制,整个过程整体还是比较顺利的,当然免不了还是会有一些坑需要填,下面就结合代码来做一个说明。
首先就服务器来说,这块不是本文的重点,而且几乎是零配置的,不过有一点需要简单的说明下,由于现在浏览器对安全性的要求比较高,正常的业务使用,不管是http网页还是webrtc传输信令所要使用的websocket协议,均是要求加密的,否则的是不允许调用音视频资源的。但是部署一套使用ssl证书加密的环境也是一件稍嫌麻烦的事情,能不能在开发阶段免去这个步骤呢?答案是肯定的。本文以谷歌浏览器为例,说明下配置方法:
第一步: 浏览器地址栏输入: chrome://flags/#unsafely-treat-insecure-origin-as-secure
第二步: 如图配置:
第三步:权限配置成功,访问页面相关功能,授权允许麦克风。
然后我们就可以欢乐的使用sip.js的各种功能了,首先,我们包含下sip.js的代码:
<script src="./js/sip-0.13.8.js"></script>
然后,我们初始化并注册一个UAC到服务器:
var extCode = "1001"; //分机号 var extPass = "1234"; //分机密码 var config = { uri: "sip:" + extCode + '@119.1.2.3:5066', authorizationUser: extCode, password: extPass, displayName: extCode, log: { builtinEnabled: true, level: 3 // log日志级别 }, transportOptions: { wsServers: ['ws://119.1.2.3:5066'], //wss协议 traceSip: true //开启sip日志,用于排查问题 }, allowLegacyNotifications: true, hackWssInTransport: false, // 设置为true 则注册时 transport=wss; false:transport=ws; hackIpInContact: "192.168.0.2", userAgentString: "smarkdeskclient", registerOptions: { expires: 300, registrar: 'sip:registrar.mydomain.com', }, contactName: "1001", }; var ua = new SIP.UA(config);
外拨电话的示例,整个过程中最大的坑就在这里了,通话本身并没有问题,但是对于早期媒体的,默认sip.js并没有很好的支持,查看了官方的文档,sip.js对于早期媒体的支持仅限于100rel的方式,而目前的线路环境是通过183信令来带有早期媒体的sdp的,因此这块就有问题了,本来想用本地的语音媒体来代替,但这样的用户体验实在是太差了,没有办法只能自立更生,有坑填坑,通过查找了一些资料并研究了下sip.js的源码,终于解决了这个问题,核心的代码其实主要就在sessionall.on('progress')中,大家可以参考:
/** * 拨打电话 */ bindEvent(startCall, 'click', function () { var number = document.getElementById("number").value; //外拨呼叫 sessionall = ua.invite(number, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false }, alwaysAcquireMediaFirst: true } }); var remoteVideo = document.getElementById('remoteVideo'); var localVideo = document.getElementById('localVideo'); //处理接受183早期媒体 sessionall.on('trackAdded', function () { var pc = this.sessionDescriptionHandler.peerConnection; var remoteStream; if (pc.getReceivers) { remoteStream = new window.MediaStream(); pc.getReceivers().forEach(function (receiver) { var track = receiver.track; if (track) { remoteStream.addTrack(track); } }); } else { remoteStream = pc.getRemoteStreams()[0]; } remoteVideo.srcObject = remoteStream; var localStream_1; if (pc.getSenders) { localStream_1 = new window.MediaStream(); pc.getSenders().forEach(function (sender) { var track = sender.track; if (track && track.kind === "video") { localStream_1.addTrack(track); } }); } else { localStream_1 = pc.getLocalStreams()[0]; } localVideo.srcObject = localStream_1; }); //每次收到成功的最终(200-299)响应时都会触发。 sessionall.on("accepted", function (response, cause) { console.log(response); var pc = this.sessionDescriptionHandler.peerConnection; var remoteStream; if (pc.getReceivers) { remoteStream = new window.MediaStream(); pc.getReceivers().forEach(function (receiver) { var track = receiver.track; if (track) { remoteStream.addTrack(track); } }); } else { remoteStream = pc.getRemoteStreams()[0]; } remoteVideo.srcObject = remoteStream; var localStream_1; if (pc.getSenders) { localStream_1 = new window.MediaStream(); pc.getSenders().forEach(function (sender) { var track = sender.track; if (track && track.kind === "video") { localStream_1.addTrack(track); } }); } else { localStream_1 = pc.getLocalStreams()[0]; } localVideo.srcObject = localStream_1; }) //挂机时会触发 sessionall.on("bye", function (response, cause) { console.log(response); }) //请求失败时触发,无论是由于最终响应失败,还是由于超时,传输或其他错误。 sessionall.on("failed", function (response, cause) { console.log(response); }) /** * */ sessionall.on("terminated", function (message, cause) { }) /** * 对方拒绝 */ sessionall.on('rejected', function (response, cause) { }) sessionall.on('progress', function (response) { if (response.statusCode === 183 && response.body && this.hasOffer && !this.dialog) { if (!response.hasHeader('require') || response.getHeader('require').indexOf('100rel') === -1) { if (this.sessionDescriptionHandler.hasDescription(response.getHeader('Content-Type'))) { if (!this.createDialog(response, 'UAC')) { // confirm the dialog, eventhough it's a provisional answer return } this.hasAnswer = true this.dialog.pracked.push(response.getHeader('rseq')) this.status = SIP.Session.C.STATUS_EARLY_MEDIA this.sessionDescriptionHandler .setDescription(response.body, this.sessionDescriptionHandlerOptions, this.modifiers) .catch((reason) => { this.logger.warn(reason) this.failed(response, C.causes.BAD_MEDIA_DESCRIPTION) this.terminate({ status_code: 488, reason_phrase: 'Bad Media Description' }) }) } } } }); })
再后面就是接听电话:
// 接受呼入会话 ua.on('invite', function (session) { var url = session.remoteIdentity.uri.toString() + "来电了,是否接听"; var remoteVideo = document.getElementById('remoteVideo'); var localVideo = document.getElementById('localVideo'); session.on("terminated", function (message, cause) { console.error(message); }) /** * */ session.on('accepted', function (response, cause) { console.error(response); console.error(session); Ring.stopRingTone(); // If there is a video track, it will attach the video and audio to the same element var pc = this.sessionDescriptionHandler.peerConnection; console.error(this.sessionDescriptionHandler); var remoteStream; if (pc.getReceivers) { remoteStream = new window.MediaStream(); pc.getReceivers().forEach(function (receiver) { var track = receiver.track; if (track) { remoteStream.addTrack(track); } }); } else { remoteStream = pc.getRemoteStreams()[0]; } remoteVideo.srcObject = remoteStream; var localStream_1; if (pc.getSenders) { localStream_1 = new window.MediaStream(); pc.getSenders().forEach(function (sender) { var track = sender.track; if (track && track.kind === "video") { localStream_1.addTrack(track); } }); } else { localStream_1 = pc.getLocalStreams()[0]; } localVideo.srcObject = localStream_1; localVideo.volume = 0; }) session.on('bye', function (resp, cause) { }); var isaccept = confirm(url); if (isaccept) { //接受来电 session.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); sessionall = session; } else { //拒绝来电 session.reject(); } });
其他的一些电话功能均再demo中实现,整个项目的demo已经开源到github,地址为webrtc phone,欢迎有需要的朋友参考,希望能对解决您的问题有所帮助!
【参考链接】
https://github.com/shanghaimoon888/webrtcphone/