简介:一份基于JsSIP与FreeSWITCH的Web软电话前端资源,面向需要在CRM或业务系统中嵌入网页电话条的开发人员,代码以完整可运行demo形式提供,可直接观察SIP注册、呼叫与来电处理的实现方式,适合有一定前端基础并希望快速接入网页通话能力的开发者。资源包整体轻量,压缩包约202KB,共22个文件,其中5个js文件承担核心话路逻辑,3个css文件负责界面布局,另有若干图片、字体及说明文档辅助使用,整体结构清晰。使用环境有明确限制,依赖FreeSWITCH开启WebSocket 5066端口,需用火狐浏览器访问且不支持HTTPS,这些前提已在描述中说明;目前已有5061人学习下载。配套内容包含index.html入口、JS逻辑、README说明与UI资源,既能用于学习SIP网页软电话的集成思路,也可在商业项目中移植改造,特别适合需要给CRM增加电话条能力的技术团队。 “在业务系统里点一下电话号码就能直接拨出去”,这个需求听起来不复杂,但真正跑通它,需要把 WebRTC、SIP、WebSocket 三样东西串在一条链路上。我这里说的就是基于 JsSIP 和 FreeSWITCH 实现的 web 软电话,也就是常说的软电话条。
如果你做过 CRM、OA、客服工单这类企业应用,大概率会遇到同样的场景:业务员看到客户列表里的号码,还得抄到座机上拨,来电话了不知道是谁,通话记录和工单系统完全不沾边。软电话条就是把这个缺口补上的东西:页面里一条常驻的窄条,自动注册分机号,支持点击呼叫、来电弹屏、挂断静音,本质上是把 FreeSWITCH 提供的话务能力封装成了浏览器里的一个组件。
这篇文章是我落地一个软电话条项目的完整复盘,从 FreeSWITCH 服务端怎么开 WebSocket 接入,到 JsSIP 客户端注册、拨号、接听的最小闭环,再到生产环境里证书、单通、回声、多标签页这些实际坑。适合负责企业通信系统集成的前端开发者、运维和通信从业者参考,不用把 SIP 协议吃透也能跑通。
1. 软电话条怎么在浏览器里跑起来:一条完整的信令与媒体链路
1.1 为什么业务系统需要一条软电话条
没有软电话条的时候,客服看一眼号码,掏出手机拨号,通话结束之后再手动往系统里补一条记录。来电更麻烦,接起来之前不知道对方是谁,只能靠耳朵听,接完再回头查客户资料。这个时候通信系统和业务系统是割裂的两个世界。
软电话条解决的是“把电话能力嵌入业务页面”这件事。用户登录系统后,软电话条自动用当前工号完成分机注册,页面上任意一个电话号码都可以点击呼叫,来电时弹窗显示号码和客户信息,挂断后把通话时长、呼叫方向这些数据回传给业务系统。客服不再需要在电话和电脑之间来回切换,工单、客户资料、通话记录在同一个界面里闭环。
1.2 SIP 负责“找到人”,WebRTC 负责“通上话”
浏览器本身没有 SIP 协议栈,不能直接和 SIP 服务器发消息。JsSIP 做的事情,是在网页里用 JavaScript 实现了一个精简的 SIP UA,让浏览器能够注册、拨号、接听,并且把 WebRTC 的音视频流和 SIP 会话绑定起来。SIP 信令不需要走传统的 UDP 5060 端口,而是通过 WebSocket 长连接承载,这就是所谓 SIP over WebSocket。
媒体面是另一条通道。浏览器用 getUserMedia 采集麦克风音频,经过编码后通过 WebRTC 的 RTP 传输给 FreeSWITCH,FreeSWITCH 再根据呼叫路由转发给目标分机或外线网关。信令负责“谁在什么时间找谁、怎么建立和拆除通话”,媒体负责“声音怎么传”,两者独立但配合工作。
你可以把 SIP 理解成前台的总机和接线规则,WebRTC 是听筒和喇叭。接线员需要知道谁在哪个房间,听筒负责把声音送到耳朵里,少了哪一个都打不成电话。
1.3 JsSIP 与 FreeSWITCH 的角色分工
FreeSWITCH 在整个架构里是核心软交换,负责分机注册、呼叫路由、通话控制、媒体中转、编码协商这些脏活累活。浏览器发来的呼叫,由 FreeSWITCH 来决定是桥接到本地另一个分机,还是通过 SIP trunk 转到运营商网络。
JsSIP 是浏览器侧的 SIP UA,职责相对单纯:维持注册状态、发起呼叫、接收来电、管理会话生命周期,以及把本地麦克风采集到的 MediaStream 和远端传来的音频接到正确的会话上。
这套架构的好处是,浏览器只是一个瘦终端,所有话务策略都在 FreeSWITCH 侧控制。以后要加排队、录音、转接、IVR,不需要动页面代码,只需要改 FreeSWITCH 的 dialplan 和配置。
2. FreeSWITCH 侧打开门:配置 SIP over WebSocket 并创建分机
2.1 开启 ws-binding 与 wss-binding
很多人第一次做这个项目会踩同一个坑:FreeSWITCH 的 internal profile 默认并不监听 WebSocket 端口,你用 Zoiper 这类 UDP 软电话注册没问题,但浏览器的 JsSIP 一直连不上。问题不在代码,而是服务器压根没“开门”。
需要修改 conf/sip_profiles/internal.xml,在 profile 里增加两个参数:
<profile name="internal"> <!-- 其他参数保持不变 --> <param name="ws-binding" value=":5066"/> <param name="wss-binding" value=":7443"/> </profile>其中 5066 是 WebSocket 明文端口,7443 是 TLS 加密端口。生产环境强烈建议只用 wss,因为浏览器安全策略会拦截非安全上下文下的 WebRTC 和 WebSocket 请求。改完后在 fs_cli 里执行sofia profile internal restart,或者直接重启 FreeSWITCH。
关于 wss 的证书,它复用 internal profile 里的 TLS 配置。生产环境必须使用合法 CA 签发的证书,并且把证书和私钥放到tls-cert-dir指定的目录。浏览器对自签证书非常敏感,不要指望在别人的电脑上能点“继续访问”绕过。
2.2 创建 Web 分机与拨号路由
浏览器注册的分机和普通 SIP 分机本质上没有任何区别,同样需要在 directory 里创建账号。在 conf/directory/default/ 下新建一个 1001.xml:
<include> <user id="1001"> <params> <param name="password" value="123456"/> </params> <variables> <variable name="user_context" value="default"/> <variable name="effective_caller_id_number" value="1001"/> </variables> </user> </include>保存后在 fs_cli 里执行reloadxml让它生效。接下来确认分机互拨的路由是否可用。FreeSWITCH 默认的 conf/dialplan/default.xml 里通常已经包含匹配分机号的 Local_Extension 规则,类似^(\d{4})$这样的表达式,会把四位分机桥接到对应目录用户。
如果你发现浏览器拨 1002 没有反应,先不要怀疑 JsSIP,先去 fs_cli 里观察呼叫的消息流程,八成是 default dialplan 里没有匹配规则,或者被 ACL 拦住了。
2.3 验证网络与端口是否就绪
配置改完先别急着写前端,先确认这几件事。服务器上执行:
ss -lnt | grep -E '5066|7443'能看到端口监听,说明 sofia 已经绑上去了。接下来在浏览器控制台手动执行:
new WebSocket('wss://yourdomain:7443');如果证书正确、网络通,这个 WebSocket 能正常建立;如果浏览器直接报证书错误,说明证书链有问题。如果连接被拒绝,检查防火墙和安全组是否放行了 7443。
最后打开 fs_cli 观察日志,浏览器发起注册时应当能看到 SIP REGISTER 消息,以及后续的 401 挑战和 200 OK。如果连接建立了但没有任何 SIP 消息,多半是 ws-binding 没有真正生效;如果看到 Access Control 相关报错,检查 internal profile 的 apply-call-acl 配置,把浏览器所在网段加入允许列表,而不是粗暴地全部放开。
3. JsSIP 接入:最小闭环代码与关键事件
3.1 初始化 UA:每个参数都在解决什么问题
假设前端项目已经安装了jssip,接下来初始化 UA。以下代码基于 JsSIP 2.x 系列的 API,这也是目前大多数存量项目在用的稳定版本。
import JsSIP from 'jssip'; const UA = new JsSIP.UA({ uri: `sip:1001@${SIP_DOMAIN}`, ws_servers: `wss://${SIP_DOMAIN}:7443`, authorization_user: '1001', password: '123456', register: true, display_name: '张三', connection_recovery_min_interval: 3, connection_recovery_max_interval: 30, keep_alive: true, keep_alive_interval: 20, session_timers: false });逐个说参数。uri是这个分机的合法 SIP 地址,相当于告诉服务器“我是谁”;ws_servers是 FreeSWITCH 的 WebSocket 接入地址;authorization_user和password用于 SIP Digest 鉴权,一般和分机号一致;register: true表示初始化后自动注册。
connection_recovery_min_interval和connection_recovery_max_interval是断线重连的最小和最大间隔,JsSIP 在网络抖动断开后会自动退避重连,这个参数直接决定恢复速度。keep_alive用来维持 WebSocket 和注册状态的心跳,避免 NAT 连接被回收。session_timers我建议先关掉,因为部分环境的会话定时器协商会导致通话意外中断,基础功能跑通后再按需开启。
3.2 注册状态判断:别在“假在线”时候做操作
UA 初始化完成不代表就可以打电话了。WebSocket 连上了不代表注册成功,注册成功不代表鉴权通过。页面里必须维护一个清晰的状态机,只允许在registered状态下点击呼叫。
ua.on('connected', () => setStatus('connecting')); ua.on('registered', () => setStatus('ready')); ua.on('unregistered', () => setStatus('offline')); ua.on('registrationFailed', (e) => { console.error('注册失败,原因:', e.cause); setStatus('error'); }); ua.on('disconnected', () => setStatus('offline'));特别注意registrationFailed事件里的cause字段。如果值是 401/403,通常是密码错误或者分机不存在,提示用户检查账号配置;如果是网络错误,说明 WSS 链路有问题。很多新手看到控制台没有报错就以为一切正常,结果点呼叫按钮一直没反应,原因就是注册根本没有成功。
3.3 呼出、来电、挂断、静音的标准组合
呼出的核心代码很直观,调用ua.call()即可:
function call(number) { const session = ua.call(`sip:${number}@${SIP_DOMAIN}`, { mediaConstraints: { audio: true, video: false }, pcConfig: { iceServers: [] }, rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: false } }); bindSessionEvents(session); }有人会问pcConfig的 iceServers 为什么是空数组。因为在这个架构里,浏览器建立的是到 FreeSWITCH 的媒体连接,FreeSWITCH 作为 B2BUA 负责媒体中转,一般不需要外部 STUN/TURN 服务。只有当你的网络拓扑特别复杂时才需要额外配置,默认空数组反而能减少 ICE 协商的耗时。
来电的处理统一挂在newRTCSession事件上:
ua.on('newRTCSession', (e) => { const session = e.session; if (session.direction !== 'incoming') return; bindSessionEvents(session); showIncomingDialog(session); });注意判断session.direction === 'incoming',因为这个事件在本地呼出时同样会触发,不判断会把呼出也当成来电。
会话事件绑定是软电话条的核心,所有状态变更都从这里驱动:
function bindSessionEvents(session) { session.on('confirmed', () => { setStatus('in-call'); startTimer(); }); session.on('ended', () => { setStatus('idle'); stopTimer(); }); session.on('failed', (e) => { console.warn('呼叫失败:', e.cause); stopTimer(); }); session.on('peerconnection', (e) => { window._currentPC = e.peerconnection; }); }接听和挂断:
session.answer({ mediaConstraints: { audio: true, video: false } }); session.terminate();静音的实现我推荐直接操作 WebRTC 的 track,而不是依赖 JsSIP 内部封装。因为有些版本对 mute 方法的兼容性不稳定,操作 track 最直接:
function setMuted(targetSession, muted) { const pc = window._currentPC; if (!pc) return; pc.getSenders().forEach((sender) => { if (sender.track && sender.track.kind === 'audio') { sender.track.enabled = !muted; } }); }DTMF 按键用于 IVR 场景,直接调用:
session.sendDTMF('1');3.4 关于 Verto 和标准 SIP over WebSocket的选型澄清
提到 FreeSWITCH 很多人会想到 Verto,那是 FreeSWITCH 自家的私有协议,搭配 mod_verto 和 vertolib.js 使用,也能实现 WebRTC 通话。但如果你的目标是做一个通用软电话条,我更推荐 JsSIP 走标准 SIP over WebSocket。
原因有三点。第一,标准 SIP 协议不绑定 FreeSWITCH,将来如果要换 Asterisk、OpenSIPS、Kamailio 或者其他运营商提供的 SIP 平台,JsSIP 这套代码几乎不用改,Verto 则完全绑死在 FreeSWITCH 上。第二,JsSIP 的社区资料和 issue 沉淀比 Verto 多得多,遇到问题搜索很容易找到方案。第三,对团队而言,标准 SIP 的排查思路可以沿用传统通信领域的经验,而 Verto 需要额外学习私有协议。
4. 软电话条的 UI 工程:状态机、来电弹窗与通话时长
4.1 常驻条的状态表达
软电话条在页面上通常是一条常驻的窄条,固定在底部或侧边。不要小看这个 UI,它承载的信息量不少:当前分机号、注册状态、是否通话中、远端号码、通话时长。
工程上建议维护一个全局状态对象:
const phoneState = { status: 'offline', // offline | connecting | ready | ringing | in-call | error remoteNumber: '', durationSec: 0 };页面上的按钮、文案、颜色全部由这个状态派生。比如status === 'ready'时显示绿色在线状态和拨号盘;status === 'in-call'时显示通话计时和挂断按钮;status === 'ringing'时显示来电弹窗。不要在回调函数里到处直接操作 DOM,否则会话一多,状态残留清理起来会非常痛苦。
4.2 来电弹窗与铃声自动播放问题的处理
浏览器自动播放策略是软电话条开发中最容易踩的坑。Chrome 等浏览器默认不允许网页在没有用户交互的情况下自动播放音频,而“播放来电铃声”恰恰需要自动播放。如果不做处理,来电时控制台会提示play() failed because the user didn't interact with the document first,用户完全听不到铃声。
解决办法建议在用户登录系统这个交互动作里提前初始化音频上下文:
const audioCtx = new AudioContext(); audioCtx.resume();这会让浏览器认为页面已经有了用户交互授权。来电时再创建 Audio 对象播放铃声,通常就能正常出声。如果产品要求页面加载后没有任何交互也必须响铃,那只能引导用户先点击一次页面任意位置,这是浏览器的安全策略,没有绕过的办法。
4.3 通话计时和挂断清理
通话计时器虽然简单,但清理不干净会带来很诡异的 bug:挂断之后 UI 仍然在走秒,甚至页面切走再回来,计时还在。我习惯给每次会话生成一个唯一 id,计时器的启动和停止都挂在会话的声明周期上。
比较好的实践是,在confirmed事件里启动 setInterval,在ended和failed事件里 clearInterval,同时把phoneState.status重置为 idle。如果框架组件被销毁(比如用户切换页面导致软电话条卸载),也要在组件的卸载钩子里统一清理。
4.4 与业务系统交互:点击号码即呼叫
软电话条本质上是一个业务系统内的通信组件,不应该和业务页面强耦合。建议把操作封装成全局方法:
window.SoftPhoneBar = { call: (number) => call(number), hangup: () => activeSession && activeSession.terminate(), getStatus: () => phoneState };业务页面里的电话号码点击事件直接调用window.SoftPhoneBar.call('1002')。来电弹屏则通过自定义事件通知业务系统:
window.dispatchEvent( new CustomEvent('incoming-call', { detail: { number: phoneState.remoteNumber } }) );业务系统监听这个事件,查询客户资料并弹窗展示。这样软电话条和 CRM 之间只通过约定好的接口通信,互不侵入。
5. 生产环境绕不开的坑:证书、单通、回声与多标签页
5.1 安全上下文与证书问题
WebRTC 的 getUserMedia 和 WSS 都必须在安全上下文中运行。localhost 是例外,但一旦部署到服务器,页面必须是 HTTPS,软电话条的 WebSocket 必须是 WSS。证书的域名必须和访问域名匹配,不能用 IP 直连。
这类问题最隐蔽的地方在于表现。UA 初始化代码完全正确,FreeSWITCH 配置也没问题,但控制台只显示WebSocket connection failed,很多人就去排查防火墙、端口、SIP 配置,折腾一圈才发现是证书链断了。排查办法很简单,浏览器打开https://yourdomain:7443,如果浏览器提示证书不受信任,那 WSS 握手一定失败。
5.2 单通/无声音的排查链路
通话已经建立,confirmed 事件也触发了,但某一方听不到声音。这种问题需要按顺序排查,不要跳步。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 双方都无声 | 浏览器没有麦克风权限,或扬声器没输出 | 检查页面左上角权限提示,确认录音已允许 |
| 单方面听不到 | FreeSWITCH NAT 配置错误,媒体包发往内网 IP | 在 profile 中配置 ext-rtp-ip 为公网地址 |
| 声音卡顿/断断续续 | 编码协商异常或网络丢包 | 查看 SDP 协商结果,确认使用 opus,检查 RTT |
| 一会有一会没有 | 回声消除把正常声音误删 | 先切耳机测试,再逐层关闭 AEC 排查 |
FreeSWITCH 在 NAT 后面时,internal profile 需要显式指定对外 IP:
<param name="ext-rtp-ip" value="你的公网IP"/> <param name="ext-sip-ip" value="你的公网IP"/>否则浏览器即使注册成功,FreeSWITCH 分发给对端的媒体地址可能仍是内网 IP,RTP 包根本到不了对端,表现就是单通。
5.3 回声与音频质量
回声的根源是麦克风采集到了扬声器播放的声音,在免提外放场景下尤其严重。浏览器侧优先开启三个音频处理开关:
mediaConstraints: { audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, video: false }这三个开关直接映射到浏览器的音频处理模块,能解决大部分回声和环境噪音问题。但如果用户用的是某些 Windows 笔记本自带麦克风阵列,即使开了 AEC 也可能残留回声。此时先让用户换耳机测试,排除物理回声后再考虑是否要从 FreeSWITCH 侧调 media bug 或改编码。
5.4 多标签页注册冲突与心跳重连
同一个分机号在浏览器开两个标签页,JsSIP 都会尝试注册,后面的注册会把前面的踢下线。在客服场景里这是很常见的事故:业务员早上开了几个标签页,到下午发现软电话条悄悄离线了。
解决方向有两种。一是业务系统登录时给每个登录会话分配一个临时分机号,标签页之间天然隔离;二是在软电话条里做单实例限制,用 localStorage 记录当前标签页,新标签页打开时提示已有软电话条在运行,并引导用户关闭旧页面。
心跳重连方面,JsSIP 的自动重连参数能处理网络波动,但业务层一定要加一个监控:定时检查ua.isConnected()和注册状态,发现掉线超过一定时间就提示用户手动重新登录,避免无限自动重试把账号卡在异常状态。
最后说一个个人体会。软电话条这种功能,技术栈本身并不算难,难点在它和业务系统的耦合。我最早只做了呼叫、挂断、来电弹窗,以为上线就完事了,后来发现用户最在乎的是来电能否自动匹配客户资料、通话结束后能否自动生成工单记录。JsSIP 和 FreeSWITCH 只是把通信能力交到你手里,真正让这条软电话条有价值的,是你对业务场景的理解。基础版跑通之后,录音留存、呼叫统计、排队策略这些方向都可以逐步加上去。
本文还有配套的精品资源,点击获取