@yinshan-tech/webrtc-sdk
v0.8.10
Published
Call-center Softphone SDK: JsSIP registration, control WS, dial HTTP, call state (no UI)
Readme
@yinshan-tech/webrtc-sdk
银杉 call-center / FreeSWITCH 的无头 Softphone(当前 0.8.10)。给各国管理后台(ap-admin、ys-web-admin、hc-admin 等)和各部门(催收 / 电销 / 客服)复用。
本库不是催收库、也不是电销库。 国家域名、坐席接口、拨号 HTTP 业务体、案件字段都由宿主注入。用户可见 locale 与后台接线入口(SoftphoneHost / createCallCenterSoftphone)在 webrtc-react;本库 messages 保持英文,且仍不解析案件 / overdue 字段。库内的点呼 / 渐进式 / 预测式是呼叫中心产品模式。
UI 请用 @yinshan-tech/webrtc-react,或自己订阅事件画界面。
规范见 docs/conventions.md。改架构 / 公开 API / 外呼行为时必须同步改 README 与 conventions。
分层
宿主(国家后台) 域名、seat、manualDialUrl、uiContext 业务字段、传 locale、催收文案、开 tab、案件 HTTP
webrtc-react 后台入口 SoftphoneHost + 电话壳三语包 + 渐进 hooks(无 antd)
webrtc-sdk SIP REGISTER、control WS、CallSnapshot、OutboundTask(不解析案件字段)
call-center 同一套 HTTP/WS;各国域名不同
FreeSWITCH 媒体安装
npm i @yinshan-tech/webrtc-sdk@^0.8npm test / npm publish 只读本仓(会 build)。催收 / 电销契约扫描在各宿主仓库。宿主本地联调可以用 file:,不要把旁路仓写进本库测试。不要擅自 bump package.json 版本。
快速开始
import { createSoftphone } from '@yinshan-tech/webrtc-sdk';
const phone = createSoftphone({
endpoints: {
controlWsUrl: 'wss://call-center.example/ws',
sipDomain: 'freeswitch.example',
sipWsUrl: 'wss://ws-freeswitch.example',
manualDialUrl: '/CALL_CENTER_BIZ/outbound/manual/dial', // 宿主业务拨号,不是催收路径
hangupCallUrl: '/CALL_CENTER/call/hangup',
outboundTaskPauseUrl: '/CALL_CENTER/progressive/task/pause/{taskId}',
outboundTaskResumeUrl: '/CALL_CENTER/progressive/task/resume/{taskId}',
outboundTaskCancelUrl: '/CALL_CENTER/progressive/task/cancel/{taskId}',
outboundTaskAgentViewUrl: '/CALL_CENTER/progressive/task/{taskId}/agent-view',
},
adapters: {
getAuthToken: () => localStorage.getItem('token'),
http: async ({ url, method, data, keepalive }) => {
const res = await myRequest(url, { method, data, keepalive });
return { success: res.success, message: res.message, data: res.data };
},
getAgentId: () => currentUser.id,
getSeatConfig: async (agentId) => ({
agentId: String(agentId),
account: '1001',
password: 'sip-password',
}),
isProduction: () => process.env.NODE_ENV === 'production',
},
media: {
turn: {
hosts: ['coturn.example'],
username: 'turn-user',
credential: 'turn-cred',
},
},
runtime: 'tab-local',
});
await phone.connect();
const result = await phone.dial({
dialPayload: { phoneNumber: '628123456789', orderId: 'opaque-host-id' },
displayName: 'Customer A',
remoteNumber: '0812****789',
uiContext: { contactRole: 'SELF' },
});
phone.subscribe((ev) => {
if (ev.type === 'call') console.log(ev.call?.phase);
if (ev.type === 'outbound-task') console.log(ev.task?.status);
if (ev.type === 'origin-lock') console.log('originBusy', ev.busy);
});
phone.setMuted(true);
await phone.hangup();
await phone.destroy();dialPayload / uiContext 对 SDK 不透明。宿主解析 result.http.data。
evaluateHttpEnvelope:2xx 仍看业务 success / code。非 2xx 先取信封 message(success === false 或 code !== 200,且 message 非空);否则 toast 响应体 trim(最长 HTTP_ERROR_BODY_TOAST_MAX 300 字 + …);空 body 才 HTTP {status}。该 message 写入 notify.message。agent-view 单次失败不 toast。
点呼
- 未
isReadyForOutbound()时先再踢一轮 REGISTER;仍失败返回not_connected,不发 HTTP。registered字符串不够。 - 挂断许可:收到
CUSTOMER_CHANNEL_PROGRESS_MEDIA后默认 500ms 可挂(allowHangupAfterProgressMediaMs);后端一直不放行则默认 10s 强制放行(forceAllowHangupMs)。 HangupSource含transport-down(显式 hangup 仍可用)。控制面或 SIP 断开且本地仍在dialing|ringing|active|ending时 不内部挂断、相位不变、不 notify;只console.log。不自动 pause / resume 渐进、不自动重拨。- 本标签已在
dialing|ringing|active|ending,或本标签渐进任务running|paused,或本标签 reserve-only hold:already_in_call(不发 notify)。 - 同 origin 其他标签已占用(live SIP、渐进
running、reserve-only hold、或预测就绪):origin_busy,不发拨号 HTTP。 - 两把锁:origin 锁 = 活 SIP 或任务
running或 reserve-only hold 或predictiveReady(跨标签)。本标签再加弹窗锁:paused不占 origin 锁,但本标签dial()仍拒绝。完成倒计时不占 origin 锁(壳层禁按钮)。就绪占用 reason 复用'task',不要加'predictive'。 - 锁按 浏览器 origin,不按分机。催收标签与电销标签(两个分机)也不能同时打。
- 实现:
BroadcastChannel+localStorage心跳(TTL 约 8s),崩溃后超时释放。 - UI 用
phone.isOriginBusy()和事件origin-lock。OutboundCallButton再叠加本标签弹窗锁。
渐进式任务
宿主注入 outboundTaskCreateUrl(与 manualDialUrl 同类,全路径)和可选 adapters.parseCreatedTaskId(工厂一次注入,只收 http.data)。确定时:createOutboundTask({ payload }) 先占 origin 锁再 POST 不透明 payload;只判信封成败,不读 Hive 字段。有钩子则调宿主解析:有 id 则内部 startOutboundTask,解不出 / 抛错 / 挂不上则放锁并 notify outbound_task_failed。无钩子则不 attach、reserved 仍占着,交回 http。
startOutboundTask({ taskId }) 仍是纯挂载。本标签活 SIP 或已有 running|paused 任务时直接 return,不覆盖。他标签占 origin 锁则 origin_busy。本标签 reserved hold 时允许 attach;attach 后 running 接管占用并清 reserved。
- pause / resume / cancel:POST 宿主注入的
{taskId}URL 模板。pause / cancel / hangup 不是占坑请求,不要先 hold。pause HTTP 成功后:ui.phase === 'dialing'保留(当前腿还在拨);waiting_next清掉以免倒计时下一通。resume 仍清 ui 并立刻 poll。 resumeOutboundTask:本标签活 SIP 或 origin 忙(含他标签点呼)时发origin_busy(现有 originBusy 文案),不发 resume HTTP。空闲则先 reserved hold 再 POST;HTTP 失败释放 reserved。- agent-view:GET,query
extNo+ SIP REGISTER Call-ID(getRegisteredCallId())。定时是上一发结束后再等 3s(尾间隔),不是墙钟setInterval。running持续拉,直到任务终态。SIPactive不再一律停:未收到 BRIDGING 且 hangup id 或uiContext仍空时(needsLiveReconcile)最多再拉 10 次(AGENT_VIEW_LIVE_RECONCILE_MAX)。pause HTTP 成功后paused最多再拉 60s(AGENT_VIEW_PAUSED_POLL_MAX_MS)。单次 poll 失败不 toast、仍续下一发定时。挂断后 1s 补拉一次;resume 立刻拉一次;二者不重置 3s。未成功暂停的paused(无本地 timestamp)不靠 3s 定时续命。 - DIALING 的 agent-view 可带顶层
callId+ 不透明businessData(不写进ui,也不拷进uiContext)。WAITING_NEXT/IDLE不应带上一通。 - BRIDGING 是 hangup /
uiContext的权威:写入后立bridgingBound。agent-view 只填空;绑定后不再覆盖。SIP 刚进ringing/active且仍需对账时立刻 GET 一次。 - WS:
PROGRESSIVE_CALL_CUSTOMER_AGENT_BRIDGING(businessData→ 不透明uiContext)、PROGRESSIVE_TASK_PROGRESS、PROGRESSIVE_TASK_AGENT_VIEW、PROGRESSIVE_TASK_COMPLETED。 - 到期
EXPIRED:无 live SIP 则结束任务;通话中不挂断,挂断后再退出。 - 关页 / destroy 必须 cancel 任务(含 paused)。同一分机多标签都会 REGISTER,后端用 REGISTER Call-ID 绑标签;不 cancel 则无法再开新任务。
pagehide一律ua.unregister()(点呼和渐进同样生效,不是预测式专用)。hangup + cancel 渐进后再void ua.unregister()。beforeunload只做离开确认:活通话 或hasOutboundTask或predictiveReady。bfcache 回前台靠现成 kick REGISTER,不要另写一套恢复。
预测式
control WS 会订阅 PREDICTIVE_*。PREDICTIVE_CALL_CUSTOMER_AGENT_BRIDGING 与渐进 BRIDGING 同一条路径:绑 callId + 不透明 businessData(applyProgressiveBridging),立 bridgingBound。PREDICTIVE_TASK_COMPLETED → SoftphoneEvent { type: 'predictive-task'; taskId; taskStatus };PREDICTIVE_AGENT_OCCUPANCY → { type: 'predictive-occupancy'; occupancy; wrapUpRemainingSeconds?; availability? }。不要把 COMPLETED 挂进 OutboundTask;宿主用 predictive-task 立刻再 GET view,禁止把事件名当签出扳机。
坐席签入预测FS 用 switchSipEndpoints + setPredictiveReady + PredictiveCheckInLock(headless 编排在 webrtc-react createPredictiveSipSession):
- 当前 SIP 等于预测媒体时,无
phoneNumber的dial/ 创建渐进 / resume →predictive_sip_active(必须先于 origin 锁)。带phoneNumber的 ops simulate 放行。 - 就绪占 origin 锁(reason
'task');暂停且本页无坐席 SIP 则放锁。预测任务拨客户不占这个坐席。 - 签入标签锁按
extNo,暂停不释放。不要因 RedischeckedIn拒 enter。 pagehide三种模式都 unregister。pagehide / leave 另发 keepalive HTTP checkout,不拆控制面、不 cancel 预测任务。
多标签
每个 createSoftphone() + connect() 独立 REGISTER(runtime: 'tab-local')。FreeSWITCH 允许多注册。点呼互斥见上文 origin 锁。未来 shared runtime 可共享一路 REGISTER,不改 dial / hangup 签名。
公开 API
| 方法 | 作用 |
|------|------|
| createSoftphone(config) | 工厂,非全局单例 |
| connect() | 坐席 + SIP + control WS |
| dial(...) | 点呼 |
| patchCallUiContext | 替换当前通话不透明 UI 元数据 |
| hangup / setMuted / unlockAudio | 挂断 / 静音 / 手势解锁铃声 |
| subscribe | registration / call / outbound-task / origin-lock / predictive-task / predictive-occupancy / notify |
| createOutboundTask | 占锁后 POST 创建 URL;有 parseCreatedTaskId 则内部挂载 |
| startOutboundTask / pause / resume / cancel | 渐进挂载 / 控制 |
| hasActiveCall | 本标签 live SIP |
| isOriginBusy | 本 origin 任一标签占用(活 SIP / 任务 running / reserved / 预测就绪) |
| tryHoldOriginLock / releaseOriginLock | 占用原语(create/resume 内部用);有 parseCreatedTaskId 时库在解不出 / 挂不上时自己 release;reserved 时本标签 dial() 拒绝,startOutboundTask 仍可 attach |
| setPredictiveReady | 预测就绪位;内部 syncOriginLock。不要只 tryHold |
| getSipMedia / switchSipEndpoints | 当前 SIP 媒体;迁册只改 sip 域名,不改 controlWsUrl,不 cleanSDK。旧域 UNREGISTER 后再重建 JsSIP。重建前摘旧 UA 监听。迁册超时不改端点;rollback 传 continueAfterUnregisterTimeout 仍切回默认并 REGISTER |
| isReadyForOutbound | 本连接控制面 session + SIP 通 + REGISTER 200(不含 SIP 下行 15s 新鲜度) |
| getRegisteredCallId | SIP REGISTER Call-ID |
| setHangupCallId / getHangupCallId | 平台通话 id(挂断 HTTP)。getter 只读内存;未绑定或已清为 null。不要把该 id 写进 uiContext |
| destroy | 清理;会 hangup + cancel 任务 |
ICE / WebRTC PC failed 保持当前通话相位(不打成 failed,不放 origin 锁),notify media_failed(英文默认 Media failed, please hang up)。真正挂断 / destroy / TTL 才释放。普通 SIP session failed 仍走 failed 并放锁。
点呼在拨号 HTTP 成功后播本地 ringbackUrl,直到运营商 183 或客户接听 / 挂断。坐席自动接听的 agent INVITE 不是 停铃条件。
connect() 先 getSeatConfig,有分机才 SIP REGISTER。waitForWsSessionId 超时必须 reject connect(),失败要 cleanSDK,避免死 promise / 双 UA。控制面断开必须作废旧 wsSessionId。WebSocket.OPEN 不是活着:online 直接换控制面 socket;pageshow / 回前台在网络可用且(长休眠则先 settle 1500ms)后发 PING 探活 2s,任意下行则不拆,零下行且在线才 forceReplace。close() 后 1s 无 onclose 必须丢掉旧引用再建。服务端 30s HEARTBEAT 保留,客户端 40s watchdog,PONG/HEARTBEAT 都 reset。已注册后传输断开 → connecting(不是 idle),在当前 UA 内再 REGISTER;connect() 不得为恢复新建 UA。新 wsSessionId 或 SIP connected 立刻 REGISTER,不要 sleep;SIP 断开取消 in-flight 并清 isRegistered / sipRegisterPending,ua.register() 前再检运输层。恢复 REGISTER:registered 事件或「已注册 + 本轮 SIP 200」即成功。恢复时 2s 无 SIP 下行则拆 SIP WS;运输标 up 但下行过期 不 先拆。connect() 等 transport 离开 READY(JsSIP disconnect() 不会自己再连)。kick 时 SIP 未 up 必须先拉起 SIP WS;点击路径已 isReadyForOutbound() 不要再置 needsRegister。外呼就绪看 isReadyForOutbound()(控制面 session + SIP 通 + REGISTER 200),不能只看 registered,也不因 SIP 静默 15s 判未就绪。回前台:未就绪才 kick;已就绪须隐藏 ≥30s 且 SIP 下行过期才探活 REGISTER。短切前台不 kick。不要每次回前台拆 SIP。同 wsSessionId 且 Contact 已带当前值不要再 ua.register()。pageshow / 标签可见 / online 先确认控制面活,400ms debounce,不 pause 任务。控制面 CONNECTING 超时从 connect() 起算。两端 ready 仍失败满 5 次 → failed + 15s 慢重试(不停)。外呼时若未就绪再踢一轮,失败则不发 HTTP。活通话传输断开 → 不 hangup、不 notify,只 console.log,相位不变,不自动 pause 渐进。首次 REGISTER 401 是 Digest 握手,JsSIP 内部重发,不发 registrationFailed。notify.code 等于 SoftphoneError.code。connect 进行中的 REGISTER_FAILED 不把 registration 打成 failed。
渐进 agent-view 必须带 taskId 才改当前任务;cancelled 会清本地任务。关页 pagehide(含 bfcache persisted)一律 hangup + cancel 渐进 + ua.unregister()。任务 completed 不 hangup 仍在通话的 SIP。
构建 / 测试
npm test只克隆本仓即可。不要在 scripts/run-tests.mjs 里读 webrtc-react / ap-admin。
从 0.3 迁移
| 0.3 | 0.8 |
|-----|-----|
| createWebRTCKit | createSoftphone |
| getReviewerId | getAgentId |
| startOutboundCall | phone.dial |
| hold / unhold | setMuted |
