
简介一份基于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 传输给 FreeSWITCHFreeSWITCH 再根据呼叫路由转发给目标分机或外线网关。信令负责“谁在什么时间找谁、怎么建立和拆除通话”媒体负责“声音怎么传”两者独立但配合工作。你可以把 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 nameinternal !-- 其他参数保持不变 -- param namews-binding value:5066/ param namewss-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.xmlinclude user id1001 params param namepassword value123456/ /params variables variable nameuser_context valuedefault/ variable nameeffective_caller_id_number value1001/ /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 didnt 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 需要显式指定对外 IPparam nameext-rtp-ip value你的公网IP/ param nameext-sip-ip value你的公网IP/否则浏览器即使注册成功FreeSWITCH 分发给对端的媒体地址可能仍是内网 IPRTP 包根本到不了对端表现就是单通。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 只是把通信能力交到你手里真正让这条软电话条有价值的是你对业务场景的理解。基础版跑通之后录音留存、呼叫统计、排队策略这些方向都可以逐步加上去。本文还有配套的精品资源点击获取