|
@@ -0,0 +1,1404 @@
|
|
|
+import { Logger, LoggerLevels } from '../Logger'
|
|
|
+import {
|
|
|
+ type Invitation,
|
|
|
+ type RegistererOptions,
|
|
|
+ type UserAgentOptions,
|
|
|
+ type LogLevel,
|
|
|
+ Registerer,
|
|
|
+ RegistererState,
|
|
|
+ SessionState,
|
|
|
+ UserAgentState,
|
|
|
+ // TransportState,
|
|
|
+ UserAgent,
|
|
|
+ Web,
|
|
|
+ Core,
|
|
|
+ URI,
|
|
|
+ RequestPendingError,
|
|
|
+ RegistererRegisterOptions
|
|
|
+} from 'sip.js'
|
|
|
+import EventEmitter from '../eventemitter'
|
|
|
+import SdSocket from './HsSocket'
|
|
|
+import {
|
|
|
+ getUserMedia,
|
|
|
+ assignStream,
|
|
|
+ checkCTIStatus,
|
|
|
+ handleApiRes,
|
|
|
+ validateParams,
|
|
|
+ generateUniqueId
|
|
|
+} from './tools'
|
|
|
+import { upperCamelToLowerSnake } from '../utils'
|
|
|
+
|
|
|
+import {
|
|
|
+ getInitConf,
|
|
|
+ getCtiFlowId,
|
|
|
+ agentCheckIn,
|
|
|
+ agentCheckOut,
|
|
|
+ agentSetIdle,
|
|
|
+ agentSetBusy,
|
|
|
+ getAgentStatus,
|
|
|
+ manualCall,
|
|
|
+ manualHang,
|
|
|
+ listen,
|
|
|
+ loadAgentGroupData,
|
|
|
+ setActiveServiceTask
|
|
|
+} from '../api/hs-cti/ctiSdk'
|
|
|
+
|
|
|
+import {
|
|
|
+ type SocketStatusChangeParams,
|
|
|
+ type SIPStatusChangeParams,
|
|
|
+ type CTIEventParams,
|
|
|
+ AudioName,
|
|
|
+ // TrackSource,
|
|
|
+ SocketEvent,
|
|
|
+ ExceptMessage
|
|
|
+} from './type'
|
|
|
+import {
|
|
|
+ type HsCTIInitOptions,
|
|
|
+ type RequiredHsCTIInitOptions,
|
|
|
+ Scene,
|
|
|
+ CTIStatus,
|
|
|
+ SocketStatus,
|
|
|
+ SIPStatus,
|
|
|
+ SessionStatus,
|
|
|
+ CTIErrorType,
|
|
|
+ SdkErrorCode,
|
|
|
+ HskTerminatedCode,
|
|
|
+ CTIEvent,
|
|
|
+ type CTIRes
|
|
|
+} from './outputType'
|
|
|
+import type {
|
|
|
+ CTIBaseOptions,
|
|
|
+ CTIManualCallOptions,
|
|
|
+ InitOptions
|
|
|
+} from '../api/hs-cti/ctiSdkModel'
|
|
|
+// import { serverTrack } from '../api/fetchApi'
|
|
|
+import {
|
|
|
+ setBaseOption,
|
|
|
+ resetBaseOption,
|
|
|
+ BaseOption
|
|
|
+ // getBaseOption
|
|
|
+} from './storage'
|
|
|
+/**
|
|
|
+ * 本地提示音
|
|
|
+ * _ringAudio 机器人外呼/监听,等待接起提示音
|
|
|
+ * _waitAudio 主动外呼,点击拨打时的等待音
|
|
|
+ * _byeAudio 结束通话提示音
|
|
|
+ */
|
|
|
+const audioList = {
|
|
|
+ _ringAudio: 'http://static.fuxicarbon.com/hs-cti/ring.wav',
|
|
|
+ _waitAudio: 'http://static.fuxicarbon.com/hs-cti/manual.wav',
|
|
|
+ _byeAudio: 'http://static.fuxicarbon.com/hs-cti/bye.wav'
|
|
|
+}
|
|
|
+
|
|
|
+/** @class SdCTI 水滴外呼类 */
|
|
|
+export class SdCTI extends EventEmitter {
|
|
|
+ private logger: Logger
|
|
|
+ public loggerLevel: LoggerLevels
|
|
|
+ public scene: Scene
|
|
|
+ public agent_id: string
|
|
|
+ public saas_id: string
|
|
|
+ /** SdCTI 单例 */
|
|
|
+ public static instance: SdCTI | undefined
|
|
|
+ /** 通过接口获取的初始化配置 */
|
|
|
+ private _initOptions: InitOptions | undefined
|
|
|
+
|
|
|
+ /** SdCTI 状态 */
|
|
|
+ private _ctiStatus!: CTIStatus
|
|
|
+ private _ctiStatusList!: CTIStatus[]
|
|
|
+ /** socket 状态 */
|
|
|
+ private _socketStatus!: SocketStatus
|
|
|
+ private _socketStatusList!: SocketStatus[]
|
|
|
+ /** sip 状态 */
|
|
|
+ private _sipStatus!: SIPStatus
|
|
|
+ private _sipStatusList!: SIPStatus[]
|
|
|
+
|
|
|
+ /** IM socket connection */
|
|
|
+ private _socket!: SdSocket
|
|
|
+ /** SIP.js 的 UserAgent 用户代理 */
|
|
|
+ private _sipUserAgent: UserAgent | undefined
|
|
|
+ /** 用户注册实例 */
|
|
|
+ private _sipRegisterer?: Registerer
|
|
|
+ /** RTC 会话 */
|
|
|
+ private _incomingSession: Invitation | undefined
|
|
|
+ /** 一次通话唯一标识 ID */
|
|
|
+ private _callId!: string
|
|
|
+ private _ctiFlowIdList!: string[]
|
|
|
+ /** 基本请求参数 */
|
|
|
+ private _baseParams: CTIBaseOptions
|
|
|
+
|
|
|
+ /** 等待音本地媒体流 */
|
|
|
+ private _waitAudio: HTMLAudioElement
|
|
|
+ /** 振铃提示音本地媒体流 */
|
|
|
+ private _ringAudio: HTMLAudioElement
|
|
|
+ /** 结束通话提示音本地媒体流 */
|
|
|
+ private _byeAudio: HTMLAudioElement
|
|
|
+ /** 远端语音流 */
|
|
|
+ private _remoteAudio: HTMLAudioElement
|
|
|
+ /** SIP心跳请求实例 */
|
|
|
+ private optionsPingRequest?: Core.OutgoingRequest
|
|
|
+ /** 是否处于心跳逻辑执行中 */
|
|
|
+ private optionsPingRunning: boolean
|
|
|
+ /** 心跳定时器 */
|
|
|
+ private optionsPingTimeout?: ReturnType<typeof setTimeout>
|
|
|
+ /** 重试规则定时器 */
|
|
|
+ private registrationAttemptTimeout?: ReturnType<typeof setTimeout>
|
|
|
+ /** 心跳失败标识,false代表心跳失败 */
|
|
|
+ private optionsPingFailure: boolean
|
|
|
+ /** UA重连是否开启 */
|
|
|
+ private shouldBeConnected: boolean
|
|
|
+ /** 是否允许注册UA */
|
|
|
+ private shouldBeRegistered: boolean
|
|
|
+ /** 重试注册次数 */
|
|
|
+ private registerAttempts: number
|
|
|
+ /**
|
|
|
+ * @param HsCTIInitOptions 初始化 SdCTI 的配置
|
|
|
+ */
|
|
|
+ private constructor(
|
|
|
+ HsCTIInitOptions: HsCTIInitOptions | RequiredHsCTIInitOptions
|
|
|
+ ) {
|
|
|
+ const { saas_id, agent_id, scene, env, loggerLevel } = HsCTIInitOptions
|
|
|
+ /** 调用父类构造函数,初始化 EventEmitter */
|
|
|
+ super()
|
|
|
+ this.loggerLevel = window.ctiLoggerLevel || loggerLevel || LoggerLevels.log
|
|
|
+ this.logger = new Logger(this.loggerLevel, 'SdCTI')
|
|
|
+ this._waitAudio = new Audio()
|
|
|
+ this._ringAudio = new Audio()
|
|
|
+ this._byeAudio = new Audio()
|
|
|
+ this._remoteAudio = new Audio()
|
|
|
+ this.shouldBeConnected = true
|
|
|
+ this.shouldBeRegistered = true
|
|
|
+ this.optionsPingRequest = undefined
|
|
|
+ this.optionsPingRunning = false
|
|
|
+ this.optionsPingFailure = false
|
|
|
+ this.registerAttempts = 10
|
|
|
+
|
|
|
+ this.saas_id = saas_id
|
|
|
+ this.agent_id = agent_id
|
|
|
+ this.scene = scene
|
|
|
+ setBaseOption(BaseOption.ENV, env)
|
|
|
+ setBaseOption(BaseOption.LoggerLevel, this.loggerLevel)
|
|
|
+
|
|
|
+ this._baseParams = { agent_id, saas_id, scene }
|
|
|
+ const baseTrackParams = { agent_id: agent_id, vcc_id: saas_id, scene }
|
|
|
+
|
|
|
+ if (scene === Scene.Monitor) {
|
|
|
+ const { monitorScene } = HsCTIInitOptions
|
|
|
+ this._baseParams = { ...this._baseParams, monitorScene }
|
|
|
+ setBaseOption(
|
|
|
+ BaseOption.TrackParams,
|
|
|
+ { ...baseTrackParams, monitor_scene: monitorScene },
|
|
|
+ true
|
|
|
+ )
|
|
|
+ } else {
|
|
|
+ setBaseOption(BaseOption.TrackParams, baseTrackParams, true)
|
|
|
+ }
|
|
|
+
|
|
|
+ this.initInstanceOptions()
|
|
|
+ this.setCTIStatus(CTIStatus.Initial)
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @public getInstance 获取 SdCTI 的实例
|
|
|
+ * @param HsCTIInitOptions 初始化 SdCTI 的配置
|
|
|
+ */
|
|
|
+ @validateParams()
|
|
|
+ public static getInstance(
|
|
|
+ HsCTIInitOptions: HsCTIInitOptions | RequiredHsCTIInitOptions
|
|
|
+ ): SdCTI {
|
|
|
+ if (!SdCTI.instance) {
|
|
|
+ SdCTI.instance = new SdCTI(HsCTIInitOptions)
|
|
|
+ }
|
|
|
+ return SdCTI.instance
|
|
|
+ }
|
|
|
+
|
|
|
+ get getCTIStatus() {
|
|
|
+ return this._ctiStatus
|
|
|
+ }
|
|
|
+ get getSocketStatus() {
|
|
|
+ return this._socketStatus
|
|
|
+ }
|
|
|
+ get getSIPStatus() {
|
|
|
+ return this._sipStatus
|
|
|
+ }
|
|
|
+ get getSessionStatus() {
|
|
|
+ return this._incomingSession
|
|
|
+ ? SessionStatus[this._incomingSession.state]
|
|
|
+ : undefined
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @private stopLocalAudio 停止本地语音提示 */
|
|
|
+ private stopLocalAudio() {
|
|
|
+ switch (this.scene) {
|
|
|
+ case Scene.Robot:
|
|
|
+ case Scene.Monitor:
|
|
|
+ this.stopAudio(AudioName.RingAudio, true)
|
|
|
+ break
|
|
|
+ case Scene.Manual:
|
|
|
+ // this.stopAudio(AudioName.WaitAudio, true)
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @private init 初始化SDK */
|
|
|
+ @getUserMedia()
|
|
|
+ public init() {
|
|
|
+ // 不允许重复初始化,需要在如果重复初始化需要提前销毁sdk
|
|
|
+ const [lastStatus] = this._ctiStatusList.slice(-1)
|
|
|
+ if (lastStatus !== CTIStatus.Initial) return
|
|
|
+ this.setAudioSrc(AudioName.ByeAudio, false)
|
|
|
+ switch (this.scene) {
|
|
|
+ case Scene.Robot:
|
|
|
+ case Scene.Monitor:
|
|
|
+ this.setAudioSrc(AudioName.RingAudio, true)
|
|
|
+ break
|
|
|
+ case Scene.Manual:
|
|
|
+ // this.setAudioSrc(AudioName.WaitAudio, true)
|
|
|
+ break
|
|
|
+ }
|
|
|
+ this.getInitConfig()
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @private getInitConfig 获取连接 socket 和 SIP.js 的配置并初始化 */
|
|
|
+ private async getInitConfig() {
|
|
|
+ const res = await getInitConf(this._baseParams)
|
|
|
+ const { code, data, msg } = res
|
|
|
+ let initOptions = data
|
|
|
+ initOptions = {
|
|
|
+ ...initOptions,
|
|
|
+ imHeartTime: 3,
|
|
|
+ // IM 重试次数
|
|
|
+ imRetryCount: 3,
|
|
|
+ // FS 心跳间隔
|
|
|
+ fsHeartTime: 60,
|
|
|
+ // FS 重试次数,
|
|
|
+ fsRetryCount: 3,
|
|
|
+ // FS 重试间隔时间
|
|
|
+ fsRetryTime: 60,
|
|
|
+ // FS 注册过期时间
|
|
|
+ fsRegisterExpireTime: 84000,
|
|
|
+ // 单次初始化唯一 ID
|
|
|
+ ctiSessionId: generateUniqueId(),
|
|
|
+ // IM websocket url
|
|
|
+ imWsServer: 'ws://192.168.100.159:8091/ws/cs-im',
|
|
|
+ // IM websocket path
|
|
|
+ imWsPath: 'ws/cs-im'
|
|
|
+ }
|
|
|
+ if (code === 0) {
|
|
|
+ setBaseOption(BaseOption.TrackParams, {
|
|
|
+ sip_server: initOptions.sipServer,
|
|
|
+ cti_session_id: initOptions.ctiSessionId
|
|
|
+ })
|
|
|
+ this._initOptions = initOptions
|
|
|
+ this.initSocket(initOptions)
|
|
|
+ this.initSIPJS(initOptions)
|
|
|
+ } else {
|
|
|
+ this.eventEmitAndTrack(CTIEvent.OnCtiError, {
|
|
|
+ type: CTIErrorType.ServerTerminated,
|
|
|
+ code: HskTerminatedCode.GetInitConfig,
|
|
|
+ msg,
|
|
|
+ method: 'getInitConfig'
|
|
|
+ })
|
|
|
+ this.initInstanceOptions()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @private initSocket 初始化 socket 连接
|
|
|
+ * @param {InitOptions} 初始化接口获取的基本配置信息
|
|
|
+ */
|
|
|
+ private initSocket(initOptions: InitOptions) {
|
|
|
+ this.setSocketStatus({ status: SocketStatus.Initial })
|
|
|
+ this._socket = new SdSocket({
|
|
|
+ agent_id: this.agent_id,
|
|
|
+ saas_id: this.saas_id,
|
|
|
+ // appCode: initOptions.appCode,
|
|
|
+ imWsServer: initOptions.imWsServer,
|
|
|
+ imWsPath: initOptions.imWsPath,
|
|
|
+ imHeartTime: initOptions.imHeartTime,
|
|
|
+ imRetryCount: initOptions.imRetryCount,
|
|
|
+ ctiSessionId: initOptions.ctiSessionId,
|
|
|
+ loggerLevel: this.loggerLevel
|
|
|
+ })
|
|
|
+
|
|
|
+ this._socket.on(SocketEvent.SocketDownEvent, ({ eventData }) => {
|
|
|
+ const { eventName, ext } = eventData
|
|
|
+ // serverTrack({
|
|
|
+ // ...getBaseOption(BaseOption.TrackParams),
|
|
|
+ // source: TrackSource.FeIMDown,
|
|
|
+ // event_name: eventName,
|
|
|
+ // ext
|
|
|
+ // })
|
|
|
+ this.logger.log(
|
|
|
+ `socket server down | ${eventName} | ${JSON.stringify(ext)}`
|
|
|
+ )
|
|
|
+ this.handleSocketDownEvent({ eventName, ext })
|
|
|
+ })
|
|
|
+
|
|
|
+ this._socket.on(SocketEvent.SetSocketStatus, res =>
|
|
|
+ this.setSocketStatus(res)
|
|
|
+ )
|
|
|
+
|
|
|
+ this._socket.initSocket()
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @private handleSocketDownEven 处理服务端下行事件 */
|
|
|
+ private handleSocketDownEvent({ eventName, ext }: CTIEventParams) {
|
|
|
+ const ctiFlowId = this._baseParams.ctiFlowId
|
|
|
+ const extCtiFlowId = ext.ctiFlowId
|
|
|
+ /** 手动外呼 & 监听场景特殊校验:cti_flow_id 一致性 */
|
|
|
+ if (
|
|
|
+ [Scene.Manual, Scene.Monitor].includes(this.scene) &&
|
|
|
+ ctiFlowId &&
|
|
|
+ extCtiFlowId &&
|
|
|
+ extCtiFlowId !== ctiFlowId
|
|
|
+ ) {
|
|
|
+ this.logger.error(
|
|
|
+ `cti_flow_id | 不一致! fe: ${ctiFlowId}, server: ${extCtiFlowId}, eventName: ${eventName}`
|
|
|
+ )
|
|
|
+ // serverTrack({
|
|
|
+ // ...getBaseOption(BaseOption.TrackParams),
|
|
|
+ // source: TrackSource.FeIMDown,
|
|
|
+ // event_name: 'socket_down_cti_flow_id_diff',
|
|
|
+ // ext: {
|
|
|
+ // server_event_name: upperCamelToLowerSnake(eventName),
|
|
|
+ // server_cti_flow_id: extCtiFlowId
|
|
|
+ // }
|
|
|
+ // })
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ this.serverEventEmit({ eventName, ext })
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @private eventEmitAndTrack SDK 对外抛出事件并统一埋点
|
|
|
+ * @param {CTIEvent} eventName 事件名称
|
|
|
+ * @param {object} ext 事件参数
|
|
|
+ * @param { error } error 错误详情或错误辅助信息
|
|
|
+ */
|
|
|
+ private eventEmitAndTrack(
|
|
|
+ eventName: CTIEvent,
|
|
|
+ ext: object,
|
|
|
+ error?: string | object
|
|
|
+ ) {
|
|
|
+ this.logger.debug(`sdk emit | ${eventName} | ${JSON.stringify(ext)}`)
|
|
|
+ // serverTrack({
|
|
|
+ // ...getBaseOption(BaseOption.TrackParams),
|
|
|
+ // source: TrackSource.FeEmit,
|
|
|
+ // event_name: eventName,
|
|
|
+ // ext,
|
|
|
+ // error
|
|
|
+ // })
|
|
|
+ try {
|
|
|
+ this.emit(eventName, ext)
|
|
|
+ console.log(error)
|
|
|
+ } catch (error) {
|
|
|
+ this.logger.error(`业务监听 ${eventName} 事件,处理回调时报错: ${error}`)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @private serverEventEmit 统一处理服务端推送的事件 */
|
|
|
+ private serverEventEmit({ eventName, ext }: CTIEventParams) {
|
|
|
+ switch (eventName) {
|
|
|
+ case CTIEvent.OnRingStart:
|
|
|
+ this.stopLocalAudio()
|
|
|
+ break
|
|
|
+ case CTIEvent.OnAgentWorkReport:
|
|
|
+ this.eventEmitAndTrack(eventName, ext)
|
|
|
+ /** 主动外呼:用户接起后停止响铃等待音 */
|
|
|
+ if (['11'].includes(ext.workStatus)) {
|
|
|
+ this.stopLocalAudio()
|
|
|
+ }
|
|
|
+ break
|
|
|
+ case CTIEvent.OnRingEnd:
|
|
|
+ case CTIEvent.OnMethodResponseEvent:
|
|
|
+ case CTIEvent.OnAgentGroupQuery:
|
|
|
+ case CTIEvent.OnEventPrompt:
|
|
|
+ case CTIEvent.OnPrompt:
|
|
|
+ /** TODO: 后 4 个事件未来服务端不再推送时删掉 */
|
|
|
+ break
|
|
|
+ default:
|
|
|
+ this.eventEmitAndTrack(eventName, ext)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @private initSIPJS 初始化 SIP.js */
|
|
|
+ private initSIPJS(initOptions: InitOptions) {
|
|
|
+ this.setSipStatus({ status: SIPStatus.Initial })
|
|
|
+
|
|
|
+ const userAgentOptions: UserAgentOptions = {
|
|
|
+ /** sip 底层依赖的 websocket 连接 */
|
|
|
+ transportOptions: {
|
|
|
+ server: initOptions.wss_server
|
|
|
+ },
|
|
|
+ /** sip 连接 */
|
|
|
+ uri: UserAgent.makeURI(initOptions.sip_server),
|
|
|
+ /** sip 日志等级 */
|
|
|
+ logLevel: LoggerLevels[this.loggerLevel] as LogLevel,
|
|
|
+ /** User-Agent 字符串的值,因为 FS 解析不了其他字段,所以把 ctiSessionId 放这了 */
|
|
|
+ userAgentString: initOptions.ctiSessionId,
|
|
|
+ /** 坐席 FS 注册密码 */
|
|
|
+ authorizationPassword: initOptions.phone_pwd,
|
|
|
+ /** SDK 默认 60,服务端目前是 30,服务端早于 SDK 即可 */
|
|
|
+ // noAnswerTimeout: 60,
|
|
|
+ /** 接受 dialog 之外的 NOTIFY */
|
|
|
+ allowLegacyNotifications: true,
|
|
|
+ /** 会话描述配置 */
|
|
|
+ sessionDescriptionHandlerFactoryOptions: {
|
|
|
+ constraints: {
|
|
|
+ audio: true,
|
|
|
+ video: false
|
|
|
+ },
|
|
|
+ /** 通路地址,用于 NAT 穿墙 */
|
|
|
+ peerConnectionConfiguration: {
|
|
|
+ iceServers: [{ urls: initOptions.ice_server }]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this._sipUserAgent = new UserAgent(userAgentOptions)
|
|
|
+ // 监听UA的状态变化
|
|
|
+ this.sipstateChangeListener()
|
|
|
+ // 设置UA的回调监听
|
|
|
+ this._sipUserAgent.delegate = this.sipDelegate()
|
|
|
+ /** 启动 UserAgent 并 Registerer 注册 */
|
|
|
+ this.connect()!.catch((error: Error) => {
|
|
|
+ const err = `SIP UserAgent 启动失败: ${error}`
|
|
|
+ this.logger.error(err)
|
|
|
+ this.setSipStatus({
|
|
|
+ status: SIPStatus.Terminated,
|
|
|
+ code: HskTerminatedCode.SIPInitUserAgent,
|
|
|
+ error: err,
|
|
|
+ method: 'initSIPJS'
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ window.addEventListener('online', () => {
|
|
|
+ this.logger.log(`Online`)
|
|
|
+ if (this.shouldBeConnected) {
|
|
|
+ this.connect()
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ private connect() {
|
|
|
+ this.shouldBeConnected = true
|
|
|
+ if (this._sipUserAgent?.state !== UserAgentState.Started) {
|
|
|
+ return this._sipUserAgent?.start()
|
|
|
+ }
|
|
|
+ return this._sipUserAgent?.reconnect()
|
|
|
+ }
|
|
|
+ /** @private sipstateChangeListener SIP.js 内置的事件监听 */
|
|
|
+ private sipstateChangeListener() {
|
|
|
+ /** SIP UserAgent 的状态监听 */
|
|
|
+ this._sipUserAgent!.stateChange.addListener((newState: UserAgentState) => {
|
|
|
+ switch (newState) {
|
|
|
+ case UserAgentState.Started:
|
|
|
+ this.setSipStatus({ status: SIPStatus.Started })
|
|
|
+ break
|
|
|
+ case UserAgentState.Stopped:
|
|
|
+ this.setSipStatus({
|
|
|
+ status: SIPStatus.Terminated,
|
|
|
+ code: this._sipStatusList.includes(SIPStatus.Started)
|
|
|
+ ? HskTerminatedCode.SIPUserAgentStateStopped
|
|
|
+ : HskTerminatedCode.SIPInitUserAgent,
|
|
|
+ error: 'SIP UserAgentState 状态停止',
|
|
|
+ method: 'sipstateChangeListener'
|
|
|
+ })
|
|
|
+ break
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ /** SIP 底层依赖的 Websocket 连接状态监听 */
|
|
|
+ // this._sipUserAgent!.transport.stateChange.addListener(
|
|
|
+ // (newState: TransportState) => {
|
|
|
+ // switch (newState) {
|
|
|
+ // case TransportState.Connecting:
|
|
|
+ // this.setSipStatus({ status: SIPStatus.Connecting })
|
|
|
+ // break
|
|
|
+ // case TransportState.Connected:
|
|
|
+ // this.setSipStatus({ status: SIPStatus.Connected })
|
|
|
+ // break
|
|
|
+ // case TransportState.Disconnected:
|
|
|
+ // this.setSipStatus({
|
|
|
+ // status: SIPStatus.Terminated,
|
|
|
+ // code: this._sipStatusList.includes(SIPStatus.Connected)
|
|
|
+ // ? HskTerminatedCode.SIPTransportStateDisconnected
|
|
|
+ // : HskTerminatedCode.SIPInitTransport,
|
|
|
+ // error: 'SIP 底层的 Webscoket 连接断开',
|
|
|
+ // method: 'sipstateChangeListener'
|
|
|
+ // })
|
|
|
+ // break
|
|
|
+ // }
|
|
|
+ // }
|
|
|
+ // )
|
|
|
+ }
|
|
|
+ /** 开始心跳检测 */
|
|
|
+ private optionsPingStart(initOptions: InitOptions): void {
|
|
|
+ this.logger.log('开始心跳检测')
|
|
|
+
|
|
|
+ const requestURI = this._sipUserAgent!.configuration.uri
|
|
|
+ const toURI = this._sipUserAgent!.configuration.uri
|
|
|
+ const fromURI = this._sipUserAgent!.userAgentCore.configuration.aor
|
|
|
+
|
|
|
+ this.optionsPingRun(requestURI, fromURI, toURI, initOptions)
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @private optionsPingRun SIP 心跳监测主逻辑 */
|
|
|
+ private optionsPingRun(
|
|
|
+ requestURI: URI,
|
|
|
+ fromURI: URI,
|
|
|
+ toURI: URI,
|
|
|
+ initOptions: InitOptions
|
|
|
+ ) {
|
|
|
+ if (initOptions.fsHeartTime < 1) {
|
|
|
+ throw new Error('缺少心跳间隔频次')
|
|
|
+ }
|
|
|
+ // 防止重复执行心跳逻辑
|
|
|
+ if (this.optionsPingRunning) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ this.optionsPingRunning = true
|
|
|
+
|
|
|
+ this.optionsPingTimeout = setTimeout(() => {
|
|
|
+ this.optionsPingTimeout = undefined
|
|
|
+ // 心跳正常后续逻辑
|
|
|
+ const onPingSuccess = () => {
|
|
|
+ this.optionsPingFailure = false
|
|
|
+ if (this.optionsPingRunning) {
|
|
|
+ this.optionsPingRunning = false
|
|
|
+ this.optionsPingRun(requestURI, fromURI, toURI, initOptions)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 心跳异常后续逻辑
|
|
|
+ const onPingFailure = (res: Core.IncomingResponse | string) => {
|
|
|
+ this.logger.error('sip心跳失败')
|
|
|
+ this.optionsPingFailure = true
|
|
|
+ this.optionsPingRunning = false
|
|
|
+ this._sipUserAgent!.transport.disconnect()
|
|
|
+ console.log(res)
|
|
|
+ // 报告链接错误,上报错误
|
|
|
+ // serverTrack({
|
|
|
+ // ...getBaseOption(BaseOption.TrackParams),
|
|
|
+ // source: TrackSource.FeSIP,
|
|
|
+ // event_name: CTIEvent.OnCtiError,
|
|
|
+ // msg: 'sip_heart_beat_err',
|
|
|
+ // method: 'optionsPingRun',
|
|
|
+ // code: HskTerminatedCode.SipHeartBeatErr,
|
|
|
+ // error: `${JSON.stringify(res)}`
|
|
|
+ // })
|
|
|
+ // this.logger.error('heartbeat' + res?.message.statusCode)
|
|
|
+ }
|
|
|
+
|
|
|
+ const core = this._sipUserAgent!.userAgentCore
|
|
|
+ const message = core.makeOutgoingRequestMessage(
|
|
|
+ 'OPTIONS',
|
|
|
+ requestURI,
|
|
|
+ fromURI,
|
|
|
+ toURI,
|
|
|
+ { userAgentString: initOptions.ctiSessionId },
|
|
|
+ []
|
|
|
+ )
|
|
|
+ this.optionsPingRequest = core.request(message, {
|
|
|
+ onAccept: res => {
|
|
|
+ this.logger.debug('heartbeat' + res.message.statusCode)
|
|
|
+ this.optionsPingRequest = undefined
|
|
|
+ onPingSuccess()
|
|
|
+ },
|
|
|
+ onReject: res => {
|
|
|
+ this.optionsPingRequest = undefined
|
|
|
+ // - 408 发送上行事件超时
|
|
|
+ // - 503 服务异常
|
|
|
+ console.warn(res)
|
|
|
+ if (
|
|
|
+ res.message.statusCode === 408 ||
|
|
|
+ res.message.statusCode === 503
|
|
|
+ ) {
|
|
|
+ onPingFailure(res)
|
|
|
+ } else {
|
|
|
+ onPingSuccess()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }, initOptions.fsHeartTime * 1000)
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 停止心跳检测 */
|
|
|
+ private optionsPingStop(): void {
|
|
|
+ this.optionsPingRunning = false
|
|
|
+ this.optionsPingFailure = false
|
|
|
+ if (this.optionsPingRequest) {
|
|
|
+ this.optionsPingRequest.dispose()
|
|
|
+ this.optionsPingRequest = undefined
|
|
|
+ }
|
|
|
+ if (this.optionsPingTimeout) {
|
|
|
+ clearTimeout(this.optionsPingTimeout)
|
|
|
+ this.optionsPingTimeout = undefined
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @private newSipRegisterer 创建 SIP Registerer
|
|
|
+ * @param {number} fsRegisterExpireTime FS 注册过期时间
|
|
|
+ */
|
|
|
+ private newSipRegisterer(fsRegisterExpireTime: number) {
|
|
|
+ const registererOptions: RegistererOptions = {
|
|
|
+ expires: fsRegisterExpireTime
|
|
|
+ }
|
|
|
+ this._sipRegisterer = new Registerer(this._sipUserAgent!, registererOptions)
|
|
|
+
|
|
|
+ /** 注册状态变化事件处理程序 */
|
|
|
+ this._sipRegisterer.stateChange.addListener((newState: RegistererState) => {
|
|
|
+ const state = `sip_registerer_state_${upperCamelToLowerSnake(newState)}`
|
|
|
+ this.logger.debug(state)
|
|
|
+ // serverTrack({
|
|
|
+ // ...getBaseOption(BaseOption.TrackParams),
|
|
|
+ // source: TrackSource.FeSIP,
|
|
|
+ // event_name: state
|
|
|
+ // })
|
|
|
+ switch (newState) {
|
|
|
+ case RegistererState.Registered:
|
|
|
+ this.setSipStatus({ status: SIPStatus.Ready })
|
|
|
+ break
|
|
|
+ case RegistererState.Unregistered:
|
|
|
+ // this.setSipStatus({
|
|
|
+ // status: SIPStatus.Terminated,
|
|
|
+ // code: this._sipStatusList.includes(SIPStatus.Ready)
|
|
|
+ // ? HskTerminatedCode.SIPRegistererStateTerminated
|
|
|
+ // : HskTerminatedCode.SIPUnRegistered,
|
|
|
+ // error: 'SIP Registerer 注册异常',
|
|
|
+ // method: 'newSipRegisterer'
|
|
|
+ // })
|
|
|
+ if (this.shouldBeRegistered) {
|
|
|
+ this.attemptRegistration()
|
|
|
+ }
|
|
|
+ break
|
|
|
+ case RegistererState.Terminated:
|
|
|
+ if (!this.shouldBeConnected) {
|
|
|
+ this.setSipStatus({
|
|
|
+ status: SIPStatus.Terminated,
|
|
|
+ code: this._sipStatusList.includes(SIPStatus.Ready)
|
|
|
+ ? HskTerminatedCode.SIPRegistererStateTerminated
|
|
|
+ : HskTerminatedCode.SIPInitRegister,
|
|
|
+ error: 'SIP Registerer 状态注销',
|
|
|
+ method: 'newSipRegisterer'
|
|
|
+ })
|
|
|
+ }
|
|
|
+ break
|
|
|
+ }
|
|
|
+ })
|
|
|
+ return this.attemptRegistration(true)
|
|
|
+ }
|
|
|
+
|
|
|
+ private attemptRegistration(withoutDelay = false): Promise<void> {
|
|
|
+ this.logger.log(`注册尝试开始${withoutDelay ? '需要等待' : '立即执行'}`)
|
|
|
+
|
|
|
+ if (this.registrationAttemptTimeout !== undefined) {
|
|
|
+ this.logger.log('正在注册')
|
|
|
+ return Promise.resolve()
|
|
|
+ }
|
|
|
+
|
|
|
+ const _register = (): Promise<void> => {
|
|
|
+ if (!this._sipRegisterer) {
|
|
|
+ this.logger.log('暂无registerer,无法执行后续逻辑')
|
|
|
+ return Promise.resolve()
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!this._sipUserAgent!.isConnected()) {
|
|
|
+ this.logger.log('SIP UserAgent未连接,无法执行后续逻行')
|
|
|
+ return Promise.resolve()
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this._sipUserAgent!.state === UserAgentState.Stopped) {
|
|
|
+ this.logger.log('SIP UA已停止,无需注册')
|
|
|
+ return Promise.resolve()
|
|
|
+ }
|
|
|
+
|
|
|
+ return this._sipRegisterer.register(this.registerOptions()).then(() => {
|
|
|
+ return
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const computeRegistrationTimeout = (lowerBound: number): number => {
|
|
|
+ const upperBound = lowerBound * 2
|
|
|
+ return 1000 * (Math.random() * (upperBound - lowerBound) + lowerBound)
|
|
|
+ }
|
|
|
+
|
|
|
+ return new Promise<void>((resolve, reject) => {
|
|
|
+ this.registrationAttemptTimeout = setTimeout(() => {
|
|
|
+ _register()
|
|
|
+ .then(() => {
|
|
|
+ this.registrationAttemptTimeout = undefined
|
|
|
+ resolve()
|
|
|
+ })
|
|
|
+ .catch(error => {
|
|
|
+ this.registrationAttemptTimeout = undefined
|
|
|
+ if (error instanceof RequestPendingError) {
|
|
|
+ resolve()
|
|
|
+ } else {
|
|
|
+ reject(error)
|
|
|
+ }
|
|
|
+ }),
|
|
|
+ withoutDelay ? 0 : computeRegistrationTimeout(1)
|
|
|
+ })
|
|
|
+ })
|
|
|
+ }
|
|
|
+ /**
|
|
|
+ * @private sessionStateChangeAndTrack SIP SessionState change event
|
|
|
+ * @param SessionState
|
|
|
+ */
|
|
|
+ private sessionStateChangeAndTrack(status: SessionState) {
|
|
|
+ this.eventEmitAndTrack(CTIEvent.OnSessionStatusChange, {
|
|
|
+ status
|
|
|
+ })
|
|
|
+ const trackName = `sip_session_state_${upperCamelToLowerSnake(status)}`
|
|
|
+ this.logger.log(trackName)
|
|
|
+ // serverTrack({
|
|
|
+ // ...getBaseOption(BaseOption.TrackParams),
|
|
|
+ // source: TrackSource.FeSIP,
|
|
|
+ // event_name: trackName
|
|
|
+ // })
|
|
|
+ }
|
|
|
+ /**
|
|
|
+ * @private register的事件接收器,例如register动作是否成功
|
|
|
+ * @returns 注册配置项
|
|
|
+ */
|
|
|
+ private registerOptions(): RegistererRegisterOptions {
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
|
+ const self: SdCTI = this
|
|
|
+ return {
|
|
|
+ requestDelegate: {
|
|
|
+ onReject(): void {
|
|
|
+ if (self.registerAttempts < 1) return
|
|
|
+ self.attemptRegistration()
|
|
|
+ self.registerAttempts--
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ /** @private sipDelegate SIP 的事件接收器,例如 FS 的 INVITE 来电,SIP 链接断开等 */
|
|
|
+ private sipDelegate() {
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
|
+ const self: SdCTI = this
|
|
|
+ return {
|
|
|
+ /** 与 FS 的连接断开事件,如果无 error 则为正常的调用 SIP.js 的 stop() 方法断开 */
|
|
|
+ onDisconnect(error?: Error): void {
|
|
|
+ let optionsPingFailure = false
|
|
|
+ optionsPingFailure = self.optionsPingFailure
|
|
|
+ self.optionsPingStop()
|
|
|
+ if (error || optionsPingFailure) {
|
|
|
+ /** 断开连接后停止发送心跳 */
|
|
|
+ // 如果已经注册过则取消注册
|
|
|
+ if (self._sipRegisterer) {
|
|
|
+ self._sipRegisterer.dispose()
|
|
|
+ self._sipRegisterer = undefined
|
|
|
+ self.shouldBeRegistered = false
|
|
|
+ }
|
|
|
+ /** 如果开启重连开关,则进行重连 */
|
|
|
+ if (self.shouldBeConnected) {
|
|
|
+ self.setSipStatus({
|
|
|
+ status: SIPStatus.ReTry
|
|
|
+ })
|
|
|
+ self.attemptReconnection()
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ self.setSipStatus({
|
|
|
+ status: SIPStatus.Terminated
|
|
|
+ })
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ onConnect(): void {
|
|
|
+ self.shouldBeRegistered = true
|
|
|
+ self.newSipRegisterer(
|
|
|
+ (self._initOptions as InitOptions).fsRegisterExpireTime
|
|
|
+ )
|
|
|
+ // 与fs建立连接成功后开始发送sip心跳
|
|
|
+ self.optionsPingStart(self._initOptions as InitOptions)
|
|
|
+ },
|
|
|
+
|
|
|
+ /** 接受来自 FS 的 INVITE 请求,即接电话事件 */
|
|
|
+ onInvite(invitation: Invitation): void {
|
|
|
+ /** 解析水滴自定义的单次会话 callId */
|
|
|
+ const callId = invitation.request.getHeader('P-LIBRA-Callid') || ''
|
|
|
+ /** 手动外呼场景下,如果 ctiFlowId 不同直接不接受 dialog 会话的任何操作 */
|
|
|
+ const ctiFlowId =
|
|
|
+ invitation.request.getHeader('P-LIBRA-CtiFlowId') || ''
|
|
|
+ if (
|
|
|
+ self.scene === Scene.Manual &&
|
|
|
+ ctiFlowId !== self._baseParams.ctiFlowId
|
|
|
+ ) {
|
|
|
+ // serverTrack({
|
|
|
+ // ...getBaseOption(BaseOption.TrackParams),
|
|
|
+ // source: TrackSource.FeSIP,
|
|
|
+ // event_name: 'sip_cti_flow_id_diff',
|
|
|
+ // ext: {
|
|
|
+ // server_cti_flow_id: ctiFlowId,
|
|
|
+ // call_id: callId
|
|
|
+ // }
|
|
|
+ // })
|
|
|
+ self.logger.error(
|
|
|
+ `cti_flow_id 不一致! fe: ${self._baseParams.ctiFlowId} | P-LIBRA-CtiFlowId: ${ctiFlowId}`
|
|
|
+ )
|
|
|
+ return
|
|
|
+ }
|
|
|
+ self._callId = callId
|
|
|
+ setBaseOption(BaseOption.TrackParams, {
|
|
|
+ call_id: callId
|
|
|
+ })
|
|
|
+ /** INVITE 初始状态 */
|
|
|
+ self.sessionStateChangeAndTrack(invitation.state)
|
|
|
+ self._incomingSession = invitation
|
|
|
+
|
|
|
+ /** 机器人外呼和监听收到 INVITE 请求只振铃,不接起 */
|
|
|
+ if ([Scene.Robot, Scene.Monitor].includes(self.scene)) {
|
|
|
+ self.playAudio(AudioName.RingAudio)
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 手动外呼和微信语音自动接起 */
|
|
|
+ if ([Scene.Manual, Scene.Wechat].includes(self.scene)) {
|
|
|
+ self.answer()
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 单次会话的事件监听 */
|
|
|
+ invitation.delegate = {
|
|
|
+ /** 通话请求被取消 */
|
|
|
+ onCancel(cancel) {
|
|
|
+ const error = `sip_invitation_on_cancel | ${cancel.request.data}`
|
|
|
+ self.logger.error(error)
|
|
|
+ self.eventEmitAndTrack(
|
|
|
+ CTIEvent.OnCtiError,
|
|
|
+ {
|
|
|
+ type: CTIErrorType.SdkError,
|
|
|
+ code: SdkErrorCode.InvitationCancel,
|
|
|
+ msg: '当前通话已结束',
|
|
|
+ method: 'sipDelegate'
|
|
|
+ },
|
|
|
+ error
|
|
|
+ )
|
|
|
+ },
|
|
|
+ /** 接受来着 FS 的 BYE 请求,并回复 200 OK */
|
|
|
+ onBye(bye) {
|
|
|
+ self.logger.log('sip_invitation_on_bye | bye.accept()')
|
|
|
+ bye.accept()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ /** 会话状态变化监听 */
|
|
|
+ invitation.stateChange.addListener((newState: SessionState) => {
|
|
|
+ self.sessionStateChangeAndTrack(newState)
|
|
|
+
|
|
|
+ switch (newState) {
|
|
|
+ case SessionState.Initial:
|
|
|
+ case SessionState.Establishing:
|
|
|
+ break
|
|
|
+ case SessionState.Established:
|
|
|
+ /** 仅 Scene.Robot 和 Scene.Monitor 在坐席接起后关闭振铃提示 */
|
|
|
+ if ([Scene.Robot, Scene.Monitor].includes(self.scene)) {
|
|
|
+ self.stopLocalAudio()
|
|
|
+ }
|
|
|
+ /** 远端语音流关联到本地 */
|
|
|
+ invitation.sessionDescriptionHandler instanceof
|
|
|
+ Web.SessionDescriptionHandler &&
|
|
|
+ assignStream(
|
|
|
+ invitation.sessionDescriptionHandler.remoteMediaStream,
|
|
|
+ self._remoteAudio,
|
|
|
+ self.logger
|
|
|
+ )
|
|
|
+ break
|
|
|
+ case SessionState.Terminating:
|
|
|
+ break
|
|
|
+ case SessionState.Terminated:
|
|
|
+ /** 播放停止通话等待音,1s 后停止 */
|
|
|
+ self.playAudio(AudioName.ByeAudio)
|
|
|
+ setTimeout(() => {
|
|
|
+ self.stopAudio(AudioName.ByeAudio, false)
|
|
|
+ }, 1000)
|
|
|
+ self.stopLocalAudio()
|
|
|
+ self._incomingSession = undefined
|
|
|
+ break
|
|
|
+ default:
|
|
|
+ break
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * UA重连逻辑
|
|
|
+ * @param reconnectionAttempt
|
|
|
+ * @returns void
|
|
|
+ * 参考来源:https://github.com/onsip/SIP.js/blob/main/src/platform/web/session-manager/session-manager.ts
|
|
|
+ */
|
|
|
+ private attemptReconnection(reconnectionAttempt = 1): void {
|
|
|
+ const reconnectionAttempts = 10
|
|
|
+ const reconnectionDelay = 3
|
|
|
+
|
|
|
+ if (!this.shouldBeConnected) {
|
|
|
+ this.logger.log('不需要重连')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (reconnectionAttempt > reconnectionAttempts) {
|
|
|
+ this.setSipStatus({
|
|
|
+ status: SIPStatus.Terminated,
|
|
|
+ code: HskTerminatedCode.SIPOnDisconnect,
|
|
|
+ error: `超过重连次数,终止重连`,
|
|
|
+ method: 'attemptReconnection'
|
|
|
+ })
|
|
|
+ this.logger.log('超过重连次数,终止重连')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (reconnectionAttempt === 1) {
|
|
|
+ this.logger.log(
|
|
|
+ `重连中:第${reconnectionAttempt}/${reconnectionAttempts}次`
|
|
|
+ )
|
|
|
+ } else {
|
|
|
+ this.logger.log(
|
|
|
+ `重连中:第${reconnectionAttempt}/${reconnectionAttempts}次,将在${reconnectionDelay}后执行`
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ setTimeout(
|
|
|
+ () => {
|
|
|
+ this._sipUserAgent
|
|
|
+ ?.reconnect()
|
|
|
+ .then(() => {
|
|
|
+ this.logger.log(
|
|
|
+ `重连第${reconnectionAttempt}/${reconnectionAttempts}次成功`
|
|
|
+ )
|
|
|
+ })
|
|
|
+ .catch(error => {
|
|
|
+ this.logger.error(
|
|
|
+ `重连第${reconnectionAttempt}/${reconnectionAttempts}次失败`
|
|
|
+ )
|
|
|
+ this.logger.error(error.message)
|
|
|
+ this.attemptReconnection(++reconnectionAttempt)
|
|
|
+ })
|
|
|
+ },
|
|
|
+ reconnectionAttempt === 1 ? 0 : reconnectionDelay * 1000
|
|
|
+ )
|
|
|
+ }
|
|
|
+ /**
|
|
|
+ * @private setSocketStatus Socket 状态流转
|
|
|
+ * @param SocketStatusChangeParams Socket 状态流转参数及错误详情等
|
|
|
+ */
|
|
|
+ private setSocketStatus({ status, code, error }: SocketStatusChangeParams) {
|
|
|
+ if (status === this.getSocketStatus) return
|
|
|
+ /** Socket 状态流转 */
|
|
|
+ this._socketStatus = status
|
|
|
+ this._socketStatusList.push(status)
|
|
|
+
|
|
|
+ const logInfo = `socket status | ${status} | ${this._socketStatusList}`
|
|
|
+ status === SocketStatus.Terminated
|
|
|
+ ? this.logger.warn(logInfo)
|
|
|
+ : this.logger.debug(logInfo)
|
|
|
+ /** 尝试修改 CTI 状态 */
|
|
|
+ this.socketOrSipStatusChange(status, this.getSIPStatus)
|
|
|
+
|
|
|
+ if (error) {
|
|
|
+ /** 抛出 Socket 类型的错误详情 */
|
|
|
+ this.eventEmitAndTrack(
|
|
|
+ CTIEvent.OnCtiError,
|
|
|
+ {
|
|
|
+ type: CTIErrorType.SdkTerminated,
|
|
|
+ code,
|
|
|
+ msg: ExceptMessage.CommonNetworkErrorMsg,
|
|
|
+ method: 'setSocketStatus'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ error_msg: error,
|
|
|
+ socket_status_list: this._socketStatusList,
|
|
|
+ sip_status_list: this._sipStatusList,
|
|
|
+ cti_status_list: this._ctiStatusList
|
|
|
+ }
|
|
|
+ )
|
|
|
+ /** 如果是被踢出的情况,只关闭 IM Socket 和 SIP Socket 的连接,不发送 Registerer 失效事件 */
|
|
|
+ if (code === HskTerminatedCode.SocketRepeatLogin) {
|
|
|
+ this.setCTIStatus(CTIStatus.Terminated)
|
|
|
+ this.initInstanceOptions()
|
|
|
+ this._socket?.closeSocket()
|
|
|
+ this._sipUserAgent?.transport.disconnect()
|
|
|
+ this._sipUserAgent = undefined
|
|
|
+ } else {
|
|
|
+ this.clearSocketAndSip()
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // serverTrack({
|
|
|
+ // ...getBaseOption(BaseOption.TrackParams),
|
|
|
+ // source: TrackSource.FeStatus,
|
|
|
+ // event_name: `socket_status_${upperCamelToLowerSnake(status)}`
|
|
|
+ // })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ /**
|
|
|
+ * @private setSipStatus SIP 状态流转
|
|
|
+ * @param SIPStatusChangeParams SIP 状态流转参数及错误详情等
|
|
|
+ */
|
|
|
+ private setSipStatus({ status, code, error, method }: SIPStatusChangeParams) {
|
|
|
+ if (status === this.getSIPStatus) return
|
|
|
+ /** SIP 状态流转 */
|
|
|
+ this._sipStatus = status
|
|
|
+ this._sipStatusList.push(status)
|
|
|
+ const logInfo = `sip status | ${status} | ${this._sipStatusList}`
|
|
|
+ status === SIPStatus.Terminated
|
|
|
+ ? this.logger.warn(logInfo)
|
|
|
+ : this.logger.debug(logInfo)
|
|
|
+ /** 尝试修改 CTI 状态 */
|
|
|
+ this.socketOrSipStatusChange(this.getSocketStatus, status)
|
|
|
+
|
|
|
+ if (error) {
|
|
|
+ /** 抛出 SIP 类型的错误详情 */
|
|
|
+ this.eventEmitAndTrack(
|
|
|
+ CTIEvent.OnCtiError,
|
|
|
+ {
|
|
|
+ type: CTIErrorType.SdkTerminated,
|
|
|
+ code,
|
|
|
+ msg: ExceptMessage.CommonNetworkErrorMsg,
|
|
|
+ method: method || 'setSipStatus'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ error_msg: error,
|
|
|
+ socket_status_list: this._socketStatusList,
|
|
|
+ sip_status_list: this._sipStatusList,
|
|
|
+ cti_status_list: this._ctiStatusList
|
|
|
+ }
|
|
|
+ )
|
|
|
+ this.clearSocketAndSip()
|
|
|
+ } else {
|
|
|
+ // serverTrack({
|
|
|
+ // ...getBaseOption(BaseOption.TrackParams),
|
|
|
+ // source: TrackSource.FeStatus,
|
|
|
+ // event_name: `sip_status_${upperCamelToLowerSnake(status)}`
|
|
|
+ // })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ /**
|
|
|
+ * @private socketOrSipStatusChange Socket 或 SIP 状态变化可能引起 CTI 状态变化
|
|
|
+ * @param {SocketStatus} socketStatus
|
|
|
+ * @param {SIPStatus} sipStatus
|
|
|
+ */
|
|
|
+ private socketOrSipStatusChange(
|
|
|
+ socketStatus: SocketStatus,
|
|
|
+ sipStatus: SIPStatus
|
|
|
+ ) {
|
|
|
+ if (socketStatus === SocketStatus.Ready && sipStatus === SIPStatus.Ready) {
|
|
|
+ this.setCTIStatus(CTIStatus.Ready)
|
|
|
+ }
|
|
|
+ if (socketStatus === SocketStatus.ReTry || sipStatus === SIPStatus.ReTry) {
|
|
|
+ this.setCTIStatus(CTIStatus.ReTry)
|
|
|
+ }
|
|
|
+ if (
|
|
|
+ socketStatus === SocketStatus.Terminated ||
|
|
|
+ sipStatus === SIPStatus.Terminated
|
|
|
+ ) {
|
|
|
+ this.setCTIStatus(CTIStatus.Terminated)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ /**
|
|
|
+ * @private setCTIStatus CTI 状态流转
|
|
|
+ * @param {CTIStatus} ctiStatus
|
|
|
+ */
|
|
|
+ private setCTIStatus(ctiStatus: CTIStatus) {
|
|
|
+ if (ctiStatus === this.getCTIStatus) return
|
|
|
+ /** CTI 状态流转 */
|
|
|
+ this._ctiStatus = ctiStatus
|
|
|
+ this._ctiStatusList.push(ctiStatus)
|
|
|
+ const logInfo = `cti status | ${ctiStatus} | ${this._ctiStatusList}`
|
|
|
+ if (ctiStatus === CTIStatus.Terminated) {
|
|
|
+ this.setSocketStatus({ status: SocketStatus.Terminated })
|
|
|
+ this.setSipStatus({ status: SIPStatus.Terminated })
|
|
|
+ this.stopLocalAudio()
|
|
|
+ this.logger.warn(logInfo)
|
|
|
+ } else {
|
|
|
+ this.logger.debug(logInfo)
|
|
|
+ }
|
|
|
+ /** TODO: 后续后端调整完逻辑把这个干掉,目前先增加如果 CTI 状态 OK 则调用后端签入 */
|
|
|
+ if (ctiStatus === CTIStatus.Ready) {
|
|
|
+ this.setSocketStatus({ status: SocketStatus.Ready })
|
|
|
+ this.setSipStatus({ status: SIPStatus.Ready })
|
|
|
+ this.checkIn()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @private initInstanceOptions 初始化实例 */
|
|
|
+ private initInstanceOptions() {
|
|
|
+ this._callId = ''
|
|
|
+ this._ctiFlowIdList = []
|
|
|
+ this._socketStatusList = []
|
|
|
+ this._sipStatusList = []
|
|
|
+ this._ctiStatusList = []
|
|
|
+ this._incomingSession = undefined
|
|
|
+ SdCTI.instance = undefined
|
|
|
+ this._initOptions = undefined
|
|
|
+ resetBaseOption()
|
|
|
+ }
|
|
|
+ /** @private clearSocketAndSip 优雅关闭 SIP 和 socket 并重置实例 */
|
|
|
+ private clearSocketAndSip() {
|
|
|
+ this.shouldBeConnected = false
|
|
|
+ this.shouldBeRegistered = false
|
|
|
+ this.initInstanceOptions()
|
|
|
+ this._socket?.closeSocket()
|
|
|
+ this._sipUserAgent?.stop()
|
|
|
+ this._sipUserAgent = undefined
|
|
|
+ this._sipRegisterer = undefined
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @private clearSocketAndSip 设置等待音 src
|
|
|
+ * @param {AudioName} audioName
|
|
|
+ * @param {boolean} loop
|
|
|
+ */
|
|
|
+ private setAudioSrc(audioName: AudioName, loop: boolean) {
|
|
|
+ this.logger.debug(`media | 设置等待音 src: ${audioList[audioName]}`)
|
|
|
+ this[audioName].src = audioList[audioName]
|
|
|
+ this[audioName].currentTime = 0
|
|
|
+ this[audioName].autoplay = false
|
|
|
+ this[audioName].loop = loop
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @private playAudio 播放等待音
|
|
|
+ * @param {AudioName} audioName
|
|
|
+ */
|
|
|
+ private playAudio(audioName: AudioName) {
|
|
|
+ this.logger.debug(`media | 播放等待音: ${audioName}`)
|
|
|
+ this[audioName].play()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @private stopAudio 停止等待音并重新设置等待音 src
|
|
|
+ * @param {AudioName} audioName
|
|
|
+ * @param {boolean} loop
|
|
|
+ */
|
|
|
+ private stopAudio(audioName: AudioName, loop: boolean) {
|
|
|
+ this.logger.debug(`media | 停止等待音: ${audioName}`)
|
|
|
+ this[audioName].src = ''
|
|
|
+ setTimeout(() => {
|
|
|
+ this.setAudioSrc(audioName, loop)
|
|
|
+ }, 1000)
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @private _getCtiFlowId 获取手动外呼场景需要的 ctiFlowId */
|
|
|
+ @handleApiRes()
|
|
|
+ private async _getCtiFlowId() {
|
|
|
+ const res = await getCtiFlowId(this._baseParams)
|
|
|
+ const { code, data } = res
|
|
|
+ if (code === 0) {
|
|
|
+ this._baseParams.ctiFlowId = data
|
|
|
+ this._ctiFlowIdList.push(data)
|
|
|
+ setBaseOption(BaseOption.TrackParams, {
|
|
|
+ cti_flow_id: data,
|
|
|
+ cti_flow_id_list: JSON.stringify(this._ctiFlowIdList)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return res
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @private checkIn 服务端签入,CTIStatus Ready 时自动调用,坐席状态变更 */
|
|
|
+ @checkCTIStatus(ExceptMessage.CommonNetworkErrorMsg)
|
|
|
+ @handleApiRes()
|
|
|
+ private async checkIn() {
|
|
|
+ const res = await agentCheckIn(this._baseParams)
|
|
|
+ if (res.code === 0) {
|
|
|
+ this.eventEmitAndTrack(CTIEvent.OnInitalSuccess, {
|
|
|
+ saas_id: this.saas_id,
|
|
|
+ agent_id: this.agent_id,
|
|
|
+ scene: this.scene,
|
|
|
+ phoneNum: this._initOptions!.phone_num,
|
|
|
+ sipServer: this._initOptions!.sip_server
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return res
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @private checkOut 服务端签出, unInit 时自动调用,坐席状态变更 */
|
|
|
+ @handleApiRes()
|
|
|
+ private async checkOut() {
|
|
|
+ return await agentCheckOut(this._baseParams)
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @public setIdle 服务端置闲,坐席状态变更 */
|
|
|
+ @checkCTIStatus(ExceptMessage.CustomNetworkErrorMsg)
|
|
|
+ @handleApiRes()
|
|
|
+ public async setIdle() {
|
|
|
+ return await agentSetIdle(this._baseParams)
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @public setBusy 服务端置忙,坐席状态变更 */
|
|
|
+ @checkCTIStatus(ExceptMessage.CommonNetworkErrorMsg)
|
|
|
+ @handleApiRes()
|
|
|
+ public async setBusy() {
|
|
|
+ return await agentSetBusy(this._baseParams)
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @public makeCall 服务端主动外呼
|
|
|
+ * @param CTIManualCallOptions
|
|
|
+ */
|
|
|
+ @checkCTIStatus(ExceptMessage.CommonNetworkErrorMsg)
|
|
|
+ @handleApiRes()
|
|
|
+ public async makeCall({ called, caller, ext }: CTIManualCallOptions) {
|
|
|
+ /** 主动外呼之前先获取 ctiFlowId,如果状态不正确直接 return */
|
|
|
+ const flowIdRes = await this._getCtiFlowId()
|
|
|
+ if (flowIdRes && flowIdRes.code !== 0) {
|
|
|
+ return flowIdRes
|
|
|
+ }
|
|
|
+ // this.playAudio(AudioName.WaitAudio)
|
|
|
+ /** 主动外呼允许增加额外入参,透传给服务端 */
|
|
|
+ let params = { ...this._baseParams, called, caller }
|
|
|
+ if (ext) {
|
|
|
+ params = { ...params, ...ext }
|
|
|
+ }
|
|
|
+ const res = await manualCall(params)
|
|
|
+ const { code, data } = res
|
|
|
+ if (code === 0) {
|
|
|
+ /** 如果 FS 的 INVITE 没取到 callId 这个逻辑是兜底 */
|
|
|
+ if (this._callId === '' && data) {
|
|
|
+ this._callId = data
|
|
|
+ setBaseOption(BaseOption.TrackParams, {
|
|
|
+ call_id: data
|
|
|
+ })
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ this.stopLocalAudio()
|
|
|
+ }
|
|
|
+ return res
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @public answer SDK SIP 接起 */
|
|
|
+ @checkCTIStatus(ExceptMessage.CommonNetworkErrorMsg)
|
|
|
+ public answer() {
|
|
|
+ return new Promise<CTIRes>((resolve, reject) => {
|
|
|
+ this._incomingSession
|
|
|
+ ?.accept({
|
|
|
+ sessionDescriptionHandlerOptions: {
|
|
|
+ constraints: {
|
|
|
+ audio: true,
|
|
|
+ video: false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .then(() => {
|
|
|
+ resolve({
|
|
|
+ code: 0,
|
|
|
+ data: 'answer',
|
|
|
+ msg: 'SIP 接起电话成功'
|
|
|
+ })
|
|
|
+ // serverTrack({
|
|
|
+ // ...getBaseOption(BaseOption.TrackParams),
|
|
|
+ // source: TrackSource.FeSIP,
|
|
|
+ // event_name: 'sip_accept_success'
|
|
|
+ // })
|
|
|
+ this.logger.debug('sip_accept_success')
|
|
|
+ })
|
|
|
+ .catch(err => {
|
|
|
+ const errorData = {
|
|
|
+ type: CTIErrorType.SdkError,
|
|
|
+ code: SdkErrorCode.Answer,
|
|
|
+ msg:
|
|
|
+ this.scene === Scene.Manual
|
|
|
+ ? ExceptMessage.ManualCallAnswerErrorMsg
|
|
|
+ : ExceptMessage.RobotOrWeChatAnswerErrorMsg,
|
|
|
+ method: 'answer'
|
|
|
+ }
|
|
|
+ reject(errorData)
|
|
|
+ this.eventEmitAndTrack(CTIEvent.OnCtiError, errorData, `${err}`)
|
|
|
+ this.logger.error(`${CTIEvent.OnCtiError} | ${err}`)
|
|
|
+ })
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @public bye SDK SIP 挂断 */
|
|
|
+ @checkCTIStatus(ExceptMessage.CommonNetworkErrorMsg)
|
|
|
+ public bye() {
|
|
|
+ return new Promise<CTIRes>((resolve, reject) => {
|
|
|
+ this._incomingSession
|
|
|
+ ?.bye()
|
|
|
+ .then(() => {
|
|
|
+ resolve({
|
|
|
+ code: 0,
|
|
|
+ data: 'bye',
|
|
|
+ msg: 'SIP 挂断电话成功'
|
|
|
+ })
|
|
|
+ // serverTrack({
|
|
|
+ // ...getBaseOption(BaseOption.TrackParams),
|
|
|
+ // source: TrackSource.FeSIP,
|
|
|
+ // event_name: 'sip_bye_success'
|
|
|
+ // })
|
|
|
+ this.logger.debug('sip_bye_success')
|
|
|
+ })
|
|
|
+ .catch(err => {
|
|
|
+ const errorData = {
|
|
|
+ type: CTIErrorType.SdkError,
|
|
|
+ code: SdkErrorCode.Bye,
|
|
|
+ msg: ExceptMessage.SipByeErrorMsg,
|
|
|
+ method: 'bye'
|
|
|
+ }
|
|
|
+ reject(errorData)
|
|
|
+ this.eventEmitAndTrack(CTIEvent.OnCtiError, errorData, `${err}`)
|
|
|
+ this.logger.error(`${CTIEvent.OnCtiError} | ${err}`)
|
|
|
+ })
|
|
|
+ .finally(() => {
|
|
|
+ if (this.scene !== Scene.Monitor) this.turnHang()
|
|
|
+ })
|
|
|
+ })
|
|
|
+ }
|
|
|
+ public async serverBye() {
|
|
|
+ return await this.bye()
|
|
|
+ }
|
|
|
+ /** @public serverBye 服务端挂断 */
|
|
|
+ @checkCTIStatus(ExceptMessage.CommonNetworkErrorMsg)
|
|
|
+ @handleApiRes()
|
|
|
+ private async turnHang() {
|
|
|
+ return await manualHang({ ...this._baseParams, callId: this._callId })
|
|
|
+ }
|
|
|
+ /** @public getAgentStatus 获取坐席状态 */
|
|
|
+ @handleApiRes()
|
|
|
+ public async getAgentStatus() {
|
|
|
+ return await getAgentStatus(this._baseParams)
|
|
|
+ }
|
|
|
+ /**
|
|
|
+ * @public loadAgentGroupData 监听-根据监听组 ID 获取监听组成员
|
|
|
+ * @param {string[]} monitorIds
|
|
|
+ */
|
|
|
+ @checkCTIStatus(ExceptMessage.CommonNetworkErrorMsg)
|
|
|
+ @handleApiRes()
|
|
|
+ public async loadAgentGroupData(monitorIds: string[]) {
|
|
|
+ return await loadAgentGroupData({ ...this._baseParams, monitorIds })
|
|
|
+ }
|
|
|
+ /**
|
|
|
+ * @public listen 监听-服务端发起监听
|
|
|
+ * @param {string} monitoredAgNo
|
|
|
+ */
|
|
|
+ @checkCTIStatus(ExceptMessage.CommonNetworkErrorMsg)
|
|
|
+ @handleApiRes()
|
|
|
+ public async listen(monitoredAgNo: string) {
|
|
|
+ /** 监听之前先获取 ctiFlowId,如果状态不正确直接 return */
|
|
|
+ const flowIdRes = await this._getCtiFlowId()
|
|
|
+ if (flowIdRes && flowIdRes.code !== 0) {
|
|
|
+ return flowIdRes
|
|
|
+ }
|
|
|
+ return await listen({
|
|
|
+ ...this._baseParams,
|
|
|
+ agent_id: monitoredAgNo,
|
|
|
+ leaderAgentId: this.agent_id
|
|
|
+ })
|
|
|
+ }
|
|
|
+ /**
|
|
|
+ * @public setActiveService 机器人外呼-签入人工组
|
|
|
+ * @param {string} serviceId
|
|
|
+ */
|
|
|
+ @checkCTIStatus(ExceptMessage.CustomNetworkErrorMsg)
|
|
|
+ @handleApiRes()
|
|
|
+ public async setActiveService(serviceId: string) {
|
|
|
+ return await setActiveServiceTask({ ...this._baseParams, serviceId })
|
|
|
+ }
|
|
|
+ /** @public unInit 卸载 SDK,checkOut 成功后断开 socket 和 sip 连接,并销毁 SdCTI 实例 */
|
|
|
+ public async unInit() {
|
|
|
+ await this.checkOut()
|
|
|
+ this.optionsPingStop()
|
|
|
+ await this.setCTIStatus(CTIStatus.Terminated)
|
|
|
+ await this.clearSocketAndSip()
|
|
|
+ }
|
|
|
+}
|