去年写了一篇Vue3使用JsSip+FreeSwitch实现网页接打电话,及一些必踩的坑的文章,后续公司业务的需要,又需要用到这样的功能,但是在不同的框架使用,所以做了一个封装,用sdk的形式在各个地方使用,本文做了详细介绍。
具体逻辑
定义变量
在类里面所有用到的变量如下
class BestCall {
//媒体控制
private constraints = {
audio: true,
video: false,
};
//创建audio控件,播放声音的地方
private audioView = document.createElement("audio");
private ua: jssip.UA;
private socket: jssip.WebSocketInterface;
// 是否监测麦克风权限
private checkMic: boolean;
//当前坐席号码
private localAgent: String;
//对方号码
// @ts-ignore
private otherLegNumber: String | undefined;
//呼叫中session:呼出、呼入、当前
private outgoingSession: RTCSession | undefined;
private incomingSession: RTCSession | undefined;
private currentSession: RTCSession | undefined;
//呼叫方向 outbound:呼出/inbound:呼入
private direction: CallDirection | undefined;
//当前通话uuid
private currentCallId: String | undefined;
//当前通话的网络延迟统计定时器(每秒钟获取网络情况)
private currentLatencyStatTimer: number | undefined;
private currentStatReport!: NetworkLatencyStat;
private stunConfig: StunConfig | undefined;
//回调函数
private stateEventListener: Function | undefined;
}
初始化
constructor传入初始化参数
constructor(config: InitConfig) {
this.localAgent = config.extNo;
this.stunConfig = config.stun;
this.checkMic = config.checkMic;
// 传入回调函数
if (config.stateEventListener !== null) {
this.stateEventListener = config.stateEventListener;
}
if (config.checkMic) {
this.micCheck();
}
this.socket = new jssip.WebSocketInterface(
`${config.fsHost}:${config.fsPort}`
);
const uri = new URI("sip", config.extNo, config.host, config.port);
const configuration = {
sockets: [this.socket],
uri: uri.toString(),
password: config.extPwd,
register_expires: 15,
session_timers: false,
user_agent: "JsSIP 3.10.1",
contact_uri: "",
};
// 为了明文展示注册信息
uri.setParam("transport", "ws");
configuration.contact_uri = uri.toString();
this.ua = new jssip.UA(configuration);
}
检测、获取麦克风
public micCheck() {
if (!navigator.mediaDevices) {
this.onChangeState(State.MIC_ERROR, {
msg: "麦克风检测异常,请检查麦克风权限是否开启,是否在HTTPS站点",
});
return;
}
navigator.mediaDevices
.getUserMedia(this.constraints)
.then((_) => {
console.log("麦克风获取成功");
_.getTracks().forEach((track) => {
track.stop();
});
})
.catch(() => {
// 拒绝
this.onChangeState(State.MIC_ERROR, {
msg: "麦克风检测异常,请检查麦克风",
});
});
}
事件监听
一般事件监听
// websocket连接成功
this.ua.on("connected", () => {
this.onChangeState(State.CONNECTED, null);
});
// websocket连接失败
this.ua.on("disconnected", (e) => {
this.ua.stop();
if (e.error) {
this.onChangeState(State.ERROR, {
msg: "websocket连接失败,请检查地址或网络",
});
}
});
// 注册成功
this.ua.on("registered", (_data) => {
console.log("注册成功---",_data);
this.onChangeState(State.REGISTERED, { localAgent: this.localAgent });
});
// 取消注册
this.ua.on("unregistered", (_e) => {
this.ua.stop();
this.onChangeState(State.UNREGISTERED, { localAgent: this.localAgent });
});
// 注册失败
this.ua.on("registrationFailed", (e) => {
this.onChangeState(State.REGISTER_FAILED, { msg: "注册失败" + e.cause });
});
// 注册到期前几秒触发
this.ua.on("registrationExpiring", () => {
this.ua.register();
});
电话事件监听(重点)
newRTCSession是整个webrtc能力实现的关键,在此处会有来电、外呼的处理逻辑,以及通话的所有状态监听。
this.ua.on(
"newRTCSession",
(data: IncomingRTCSessionEvent | OutgoingRTCSessionEvent) => {
const session = data.session;
let currentEvent: String;
if (data.originator === "remote") {
// 远程来电
this.incomingSession = data.session;
this.currentSession = this.incomingSession;
this.direction = CallDirectionEnum.INBOUND;
currentEvent = State.INCOMING_CALL;
} else {
// 外呼
this.direction = CallDirectionEnum.OUTBOUND;
currentEvent = State.OUTGOING_CALL;
}
session.on("peerconnection", (evt: PeerConnectionEvent) => {
// 处理媒体流
this.handleAudio(evt.peerconnection);
});
session.on("connecting", () => {
console.log("connecting");
});
// 确保 ICE 候选者的正确处理
let iceCandidateTimeout: number;
session.on("icecandidate", (evt: IceCandidateEvent) => {
if (iceCandidateTimeout) {
clearTimeout(iceCandidateTimeout);
}
// srflx:stun服务发现的候选者,relay:turn服务发现的候选者
if (
evt.candidate.type === "srflx" ||
evt.candidate.type === "relay"
) {
evt.ready();
}
iceCandidateTimeout = setTimeout(evt.ready, 1000);
});
// 来电振铃
session.on("progress", (_evt: IncomingEvent | OutgoingEvent) => {
this.onChangeState(currentEvent, {
direction: this.direction,
otherLegNumber: data.request.from.uri.user,
// @ts-ignore
// callId: data.request.call_id,
callId: data.session.id,
});
});
// 来电接通
session.on("accepted", () => {
this.onChangeState(State.IN_CALL, null);
});
// 来电挂断
session.on("ended", (evt: EndEvent) => {
const evtData: CallEndEvent = {
answered: true,
cause: evt.cause,
// @ts-ignore
code: evt.message?.status_code ?? 0,
originator: evt.originator,
};
this.cleanCallingData();
this.onChangeState(State.CALL_END, evtData);
});
// 来电失败
session.on("failed", (evt: EndEvent) => {
const evtData: CallEndEvent = {
answered: false,
cause: evt.cause,
// @ts-ignore
code: evt.message?.status_code ?? 0,
originator: evt.originator,
};
this.cleanCallingData();
this.onChangeState(State.CALL_END, evtData);
});
// 通话保持
session.on("hold", (_evt: HoldEvent) => {
this.onChangeState(State.HOLD, null);
});
// 通话恢复
session.on("unhold", (_evt: HoldEvent) => {
this.onChangeState(State.IN_CALL, null);
});
}
);
//初始化最后启动UA
this.ua.start();
回调函数(重点)
回调函数是sdk与页面通信的唯一途径,在初始化的时候传入,所有状态的变更都由回调函数暴露出去
private onChangeState(
event: String,
data:
| StateListenerMessage
| CallEndEvent
| LatencyStat
| string
| null
| undefined
) {
if (undefined === this.stateEventListener) {
return;
}
this.stateEventListener(event, data);
}
外呼
可通过此方法从本地发起呼叫
public call(phone: string, param: CallExtraParam = {}) {
this.checkMic && this.micCheck();
this.currentCallId = uuidv4();
if (this.ua && this.ua.isRegistered()) {
const extraHeaders: string[] = ["X-JCallId: " + this.currentCallId];
if (param) {
if (param.businessId) {
extraHeaders.push("X-JBusinessId: " + param.businessId);
}
if (param.outNumber) {
extraHeaders.push("X-JOutNumber: " + param.outNumber);
}
}
this.outgoingSession = this.ua.call(phone, {
eventHandlers: {
peerconnection: (e: { peerconnection: RTCPeerConnection }) => {
this.handleAudio(e.peerconnection);
},
},
mediaConstraints: this.constraints,
extraHeaders,
sessionTimersExpires: 120,
pcConfig: this.getCallOptionPcConfig(),
});
this.currentSession = this.outgoingSession;
this.otherLegNumber = phone;
return this.currentCallId;
} else {
this.onChangeState(State.ERROR, { msg: "请在注册成功后再发起外呼请求." });
return "";
}
}
处理媒体流
处理了媒体流的播放和网络情况的统计,网络延迟数据通过回调函数每1s暴露一次,可结合css实现类似手机信号强弱的展示。
private handleAudio(pc: RTCPeerConnection) {
this.audioView.autoplay = true;
// 网络情况统计
this.currentStatReport = {
outboundPacketsSent: 0,
outboundLost: 0,
inboundLost: 0,
inboundPacketsSent: 0,
};
this.currentLatencyStatTimer = setInterval(() => {
pc.getStats().then((stats) => {
stats.forEach((report) => {
if (report.type == "media-source") {
this.currentStatReport.outboundAudioLevel = report.audioLevel;
}
if (
report.type != "remote-inbound-rtp" &&
report.type != "inbound-rtp" &&
report.type != "remote-outbound-rtp" &&
report.type != "outbound-rtp"
) {
return;
}
switch (report.type) {
case "outbound-rtp": // 客户端发送的-上行
this.currentStatReport.outboundPacketsSent = report.packetsSent;
break;
case "remote-inbound-rtp": //服务器收到的-对于客户端来说也就是上行
this.currentStatReport.outboundLost = report.packetsLost;
//延时(只会在这里有这个)
this.currentStatReport.roundTripTime = report.roundTripTime;
break;
case "inbound-rtp": //客户端收到的-下行
this.currentStatReport.inboundLost = report.packetsLost;
this.currentStatReport.inboundAudioLevel = report.audioLevel;
break;
case "remote-outbound-rtp": //服务器发送的-对于客户端来说就是下行
this.currentStatReport.inboundPacketsSent = report.packetsSent;
break;
}
});
let ls: LatencyStat = {
latencyTime: 0,
upLossRate: 0,
downLossRate: 0,
downAudioLevel: 0,
upAudioLevel: 0,
};
if (this.currentStatReport.inboundAudioLevel != undefined) {
ls.downAudioLevel = this.currentStatReport.inboundAudioLevel;
}
if (this.currentStatReport.outboundAudioLevel != undefined) {
ls.upAudioLevel = this.currentStatReport.outboundAudioLevel;
}
if (
this.currentStatReport.inboundLost &&
this.currentStatReport.inboundPacketsSent
) {
ls.downLossRate =
this.currentStatReport.inboundLost /
this.currentStatReport.inboundPacketsSent;
}
if (
this.currentStatReport.outboundLost &&
this.currentStatReport.outboundPacketsSent
) {
ls.upLossRate =
this.currentStatReport.outboundLost /
this.currentStatReport.outboundPacketsSent;
}
if (this.currentStatReport.roundTripTime != undefined) {
ls.latencyTime = Math.floor(
this.currentStatReport.roundTripTime * 1000
);
}
console.debug(
"上行/下行(丢包率):" +
(ls.upLossRate * 100).toFixed(2) +
"% / " +
(ls.downLossRate * 100).toFixed(2) +
"%",
"延迟:" + ls.latencyTime.toFixed(2) + "ms"
);
this.onChangeState(State.LATENCY_STAT, ls);
});
}, 1000);
if ("addTrack" in pc) {
pc.ontrack = (media) => {
if (media.streams.length > 0 && media.streams[0].active) {
this.audioView.srcObject = media.streams[0];
}
};
} else {
// @ts-ignore
pc.onaddstream = (media: { stream: any }) => {
const remoteStream = media.stream;
if (remoteStream.active) {
this.audioView.srcObject = remoteStream;
}
};
}
}
当前通话是否可用
检查当前通话是否可用,用于保持、静音、转接等操作。
private checkCurrentCallIsActive(): boolean {
if (!this.currentSession || !this.currentSession.isEstablished()) {
this.onChangeState(State.ERROR, {
msg: "当前通话不存在或已销毁,无法执行该操作。",
});
return false;
}
return true;
}
注册、取消注册
public register() {
if (this.ua.isConnected()) {
this.ua.register();
} else {
this.onChangeState(State.ERROR, {
msg: "websocket尚未连接,请先连接ws服务器.",
});
}
}
//取消注册
public unregister() {
if (this.ua && this.ua.isConnected() && this.ua.isRegistered()) {
this.ua.unregister({ all: true });
} else {
this.onChangeState(State.ERROR, { msg: "尚未注册,操作禁止." });
}
}
ICE 服务器设置
public getCallOptionPcConfig(): RTCConfiguration | undefined {
if (this.stunConfig && this.stunConfig.type && this.stunConfig.host) {
if ("turn" === this.stunConfig.type) {
return {
iceTransportPolicy: "all",
iceServers: [
{
username: this.stunConfig.username,
// @ts-ignore
credentialType: "password",
credential: this.stunConfig.password,
urls: [this.stunConfig.type + ":" + this.stunConfig.host],
},
],
};
} else {
return {
iceTransportPolicy: "all",
iceServers: [
{
urls: [this.stunConfig.type + ":" + this.stunConfig.host],
},
],
};
}
} else {
return undefined;
}
}
应答事件
来电可调用此方法接起通话
public answer() {
if (this.currentSession && this.currentSession.isInProgress()) {
this.currentSession.answer({
mediaConstraints: this.constraints,
pcConfig: this.getCallOptionPcConfig(),
});
} else {
this.onChangeState(State.ERROR, {
msg: "非法操作,通话尚未建立或状态不正确,请勿操作",
});
}
}
挂断事件
来电外呼的通话都可调用此方法
public hangup() {
if (this.currentSession && !this.currentSession.isEnded()) {
this.currentSession.terminate();
} else {
this.onChangeState(State.ERROR, {
msg: "当前通话不存在,无法执行挂断操作。",
});
}
}
保持、取消保持
// 保持
public hold() {
if (!this.currentSession || !this.checkCurrentCallIsActive()) {
return;
}
this.currentSession.hold();
}
//取消保持
public unhold() {
if (!this.currentSession || !this.checkCurrentCallIsActive()) {
return;
}
if (!this.currentSession.isOnHold()) {
return;
}
this.currentSession.unhold();
}
静音、取消静音
// 静音
public mute() {
if (!this.currentSession || !this.checkCurrentCallIsActive()) {
return;
}
this.currentSession.mute();
this.onChangeState(State.MUTE, null);
}
//取消静音
public unmute() {
if (!this.currentSession || !this.checkCurrentCallIsActive()) {
return;
}
this.currentSession.unmute();
this.onChangeState(State.UNMUTE, null);
}
转接
public transfer(phone: string) {
if (!this.currentSession || !this.checkCurrentCallIsActive()) {
return;
}
this.currentSession.refer(phone);
}
清除通话数据
private cleanCallingData() {
this.outgoingSession = undefined;
this.incomingSession = undefined;
this.currentSession = undefined;
this.direction = undefined;
this.otherLegNumber = "";
this.currentCallId = "";
clearInterval(this.currentLatencyStatTimer);
this.currentLatencyStatTimer = undefined;
this.currentStatReport = {
outboundPacketsSent: 0,
outboundLost: 0,
inboundLost: 0,
inboundPacketsSent: 0,
};
}
退出系统
public cleanSdk() {
this.cleanCallingData();
this.ua.stop();
}