|  |  | 
 |  |  |   constructor() { | 
 |  |  |     this.ua = null; | 
 |  |  |     this.currentSession = null; | 
 |  |  |     this.onStatusChange = null; | 
 |  |  |     this.onCallStatusChange = null; | 
 |  |  |     this.onIncomingCall = null; | 
 |  |  |     this.isRegistered = false; // æ°å¢æ³¨åç¶ææ å¿ | 
 |  |  |     this.registrationTime = null; // æ°å¢æ³¨åæåæ¶é´æ³ | 
 |  |  |     this.onStatusChange = null; // ç¶æåååè° | 
 |  |  |     this.onCallStatusChange = null; // æ°å¢éè¯ç¶æåè° | 
 |  |  |   } | 
 |  |  |  | 
 |  |  |   // åå§åSIP客æ·ç«¯ | 
 |  |  |   init(config) { | 
 |  |  |     try { | 
 |  |  |       this.updateStatus("connecting", "è¿æ¥ä¸;..."); | 
 |  |  |       this.updateStatus("connecting", "è¿æ¥ä¸..."); | 
 |  |  |  | 
 |  |  |       this.ua = new JsSIP.UA({ | 
 |  |  |         sockets: [new JsSIP.WebSocketInterface(config.wsUrl)], | 
 |  |  |         uri: config.sipUri, | 
 |  |  |         password: config.password, | 
 |  |  |         display_name: config.displayName, | 
 |  |  |         iceServers: [], | 
 |  |  |         iceservers: [], | 
 |  |  |         // realm: config.realm, | 
 |  |  |         register: true, | 
 |  |  |         sessionExpires: 1800, | 
 |  |  |         minSessionExpires: 90, | 
 |  |  |         register_expires: 300, | 
 |  |  |         session_expires: 180, | 
 |  |  |         sessionTimersExpires: 300, // è®¾ç½® Session-Expires=120ï¼å¿
é¡» >= Min-SEï¼ | 
 |  |  |         extraHeaders: [ | 
 |  |  |           "Min-SE: 120", // å¯éï¼æ¾å¼åè¯æå¡å¨ä½ æ¯æçæå°å¼ | 
 |  |  |         ], | 
 |  |  |         register_expires: 300, // æ³¨åæææ(ç§) | 
 |  |  |         connection_recovery_min_interval: 2, // æå°éè¿é´é | 
 |  |  |         connection_recovery_max_interval: 30, // æå¤§éè¿é´é | 
 |  |  |       }); | 
 |  |  |  | 
 |  |  |       this.ua.start(); | 
 |  |  |  | 
 |  |  |       // äºä»¶çå¬ | 
 |  |  |       // æ³¨åäºä»¶çå¬ | 
 |  |  |       this.ua.on("registered", () => { | 
 |  |  |         this.isRegistered = true; | 
 |  |  |         this.registrationTime = Date.now(); // è®°å½æ³¨åæåæ¶é´ | 
 |  |  |         console.log(this.registrationTime, "æ³¨åæ¶é´"); | 
 |  |  |  | 
 |  |  |         this.updateStatus("registered", "已注å"); | 
 |  |  |       }); | 
 |  |  |  | 
 |  |  |       this.ua.on("registrationFailed", (e) => { | 
 |  |  |         this.isRegistered = false; | 
 |  |  |         this.updateStatus("failed", `注å失败: ${e.cause}`); | 
 |  |  |       }); | 
 |  |  |  | 
 |  |  |       this.ua.on("unregistered", () => { | 
 |  |  |         this.isRegistered = false; | 
 |  |  |         let registrationTime = Date.now(); // è®°å½æ³¨éæåæ¶é´ | 
 |  |  |         console.log(registrationTime, "æ³¨éæ¶é´"); | 
 |  |  |         this.updateStatus("disconnected", "已注é"); | 
 |  |  |       this.ua.on("disconnected", () => { | 
 |  |  |         this.updateStatus("disconnected", "è¿æ¥æå¼"); | 
 |  |  |       }); | 
 |  |  |       this.ua.on("disconnected", () => | 
 |  |  |         this.updateStatus("disconnected", "è¿æ¥æå¼") | 
 |  |  |       ); | 
 |  |  |       this.ua.on("connected", () => | 
 |  |  |         this.updateStatus("connecting", "éæ°è¿æ¥ä¸...") | 
 |  |  |       ); | 
 |  |  |       this.ua.on("newRTCSession", (data) => | 
 |  |  |         this.handleIncomingCall(data.session) | 
 |  |  |       ); | 
 |  |  |  | 
 |  |  |       this.ua.on("connected", () => { | 
 |  |  |         this.updateStatus("connecting", "éæ°è¿æ¥ä¸..."); | 
 |  |  |       }); | 
 |  |  |  | 
 |  |  |       // ç嬿¥çµ | 
 |  |  |       this.ua.on("newRTCSession", (data) => { | 
 |  |  |         this.handleIncomingCall(data.session); | 
 |  |  |       }); | 
 |  |  |     } catch (error) { | 
 |  |  |       this.updateStatus("failed", `åå§å失败: ${error.message}`); | 
 |  |  |       console.error("SIPåå§å失败:", error); | 
 |  |  |       throw error; | 
 |  |  |     } | 
 |  |  |   } | 
 |  |  |   // æ°å¢æ¹æ³ï¼æ£æ¥æ¯å¦å¯ä»¥å¼å« | 
 |  |  |   canMakeCall(minDelay = 2000) { | 
 |  |  |     if (!this.isRegistered) { | 
 |  |  |       return { canCall: false, reason: "SIPæªæ³¨åï¼æ æ³å¼å«" }; | 
 |  |  |     } | 
 |  |  |  | 
 |  |  |     const now = Date.now(); | 
 |  |  |     const timeSinceRegistration = now - this.registrationTime; | 
 |  |  |  | 
 |  |  |     if (timeSinceRegistration < minDelay) { | 
 |  |  |       const remaining = minDelay - timeSinceRegistration; | 
 |  |  |       return { | 
 |  |  |         canCall: false, | 
 |  |  |         reason: `注åæåï¼è¯·çå¾
 ${Math.ceil(remaining / 1000)} ç§ååå¼å«`, | 
 |  |  |       }; | 
 |  |  |     } | 
 |  |  |  | 
 |  |  |     return { canCall: true, reason: "" }; | 
 |  |  |   } | 
 |  |  |   makeCall(targetNumber) { | 
 |  |  |     const { canCall, reason } = this.canMakeCall(); | 
 |  |  |     if (!canCall) { | 
 |  |  |       return Promise.reject(new Error(reason)); | 
 |  |  |     } | 
 |  |  |     return new Promise((resolve, reject) => { | 
 |  |  |       try { | 
 |  |  |         if (!this.ua) { | 
 |  |  |           throw new Error("SIP客æ·ç«¯æªåå§å"); | 
 |  |  |         } | 
 |  |  |  | 
 |  |  |         if (!this.ua.isRegistered()) { | 
 |  |  |           throw new Error("SIPæªæ³¨åï¼æ æ³å¼å«"); | 
 |  |  |         } | 
 |  |  |  | 
 |  |  |         const options = { | 
 |  |  |           sessionTimers: true, // å¯ç¨ä¼è¯è®¡æ¶å¨ | 
 |  |  |           sessionTimersExpires: 150, | 
 |  |  |           extraHeaders: ["Accept: application/sdp"], | 
 |  |  |           mediaConstraints: { audio: true, video: false }, | 
 |  |  |           rtcOfferConstraints: { | 
 |  |  |             offerToReceiveAudio: true, | 
 |  |  |             offerToReceiveVideo: false, | 
 |  |  |           }, | 
 |  |  |           eventHandlers: { | 
 |  |  |             progress: () => this.updateCallStatus("calling", "å¼å«ä¸..."), | 
 |  |  |             failed: (e) => { | 
 |  |  |               this.handleCallFailure(e, reject); | 
 |  |  |             }, | 
 |  |  |             ended: () => this.updateCallStatus("ended", "éè¯ç»æ"), | 
 |  |  |             confirmed: () => { | 
 |  |  |               this.updateCallStatus("connected", "éè¯å·²æ¥é"); | 
 |  |  |               resolve(); | 
 |  |  |             }, | 
 |  |  |           }, | 
 |  |  |         }; | 
 |  |  |  | 
 |  |  |         this.currentSession = this.ua.call( | 
 |  |  |           `sip:${targetNumber}@1192.170.66.107`, | 
 |  |  |           options | 
 |  |  |         ); | 
 |  |  |  | 
 |  |  |         this.setupPeerConnection(this.currentSession); | 
 |  |  |         this.setupAudio(this.currentSession); | 
 |  |  |       } catch (error) { | 
 |  |  |         this.updateCallStatus("failed", `å¼å«å¤±è´¥22: ${error.message}`); | 
 |  |  |         reject(error); | 
 |  |  |   handleIncomingCall(session) { | 
 |  |  |     if (session.direction === "incoming") { | 
 |  |  |       console.log("æ¥çµ:", session.remote_identity.uri.toString()); | 
 |  |  |       // å¯ä»¥å¨è¿é触å UI éç¥ | 
 |  |  |       if (this.onIncomingCall) { | 
 |  |  |         this.onIncomingCall(session); | 
 |  |  |       } | 
 |  |  |     }); | 
 |  |  |     } | 
 |  |  |   } | 
 |  |  |  | 
 |  |  |   setupPeerConnection(session) { | 
 |  |  |     session.on("peerconnection", (pc) => { | 
 |  |  |       const originalCreateOffer = pc.createOffer.bind(pc); | 
 |  |  |   // æ´æ°ç¶æå¹¶éç¥UI | 
 |  |  |   updateStatus(type, text) { | 
 |  |  |     console.log(`SIPç¶ææ´æ°: ${type} - ${text}`); | 
 |  |  |     if (this.onStatusChange) { | 
 |  |  |       this.onStatusChange({ type, text }); | 
 |  |  |     } | 
 |  |  |   } | 
 |  |  |  | 
 |  |  |       pc.createOffer = async (offerOptions) => { | 
 |  |  |         try { | 
 |  |  |           const offer = await originalCreateOffer(offerOptions); | 
 |  |  |           return this.normalizeSDP(offer); | 
 |  |  |         } catch (error) { | 
 |  |  |           console.error("å建Offer失败:", error); | 
 |  |  |           throw error; | 
 |  |  |         } | 
 |  |  |   // ä¸é®æ¨å· - å¢å æ³¨åç¶ææ£æ¥ | 
 |  |  |   makeCall(targetNumber) { | 
 |  |  |     if (!this.ua) { | 
 |  |  |       throw new Error("SIP客æ·ç«¯æªåå§å"); | 
 |  |  |     } | 
 |  |  |  | 
 |  |  |     if (!this.ua.isRegistered()) { | 
 |  |  |       throw new Error("SIPæªæ³¨åï¼æ æ³å¼å«"); | 
 |  |  |     } | 
 |  |  |  | 
 |  |  |     const options = { | 
 |  |  |       sessionTimers: true, | 
 |  |  |       sessionTimersExpires: 300, | 
 |  |  |       extraHeaders: [ | 
 |  |  |         "Min-SE: 120", | 
 |  |  |         "Route: <sip:@192.168.100.6>", | 
 |  |  |         "Accept: application/sdp", | 
 |  |  |         "Supported: replaces, timer", | 
 |  |  |         "Allow: INVITE, ACK, BYE, CANCEL, OPTIONS", | 
 |  |  |       ], | 
 |  |  |       eventHandlers: { | 
 |  |  |         progress: (e) => { | 
 |  |  |           this.updateCallStatus("calling", "å¼å«ä¸..."); | 
 |  |  |         }, | 
 |  |  |         failed: (e) => { | 
 |  |  |           this.updateCallStatus("ended", `å¼å«å¤±è´¥: ${e.cause}`); | 
 |  |  |         }, | 
 |  |  |         ended: (e) => { | 
 |  |  |           this.updateCallStatus("ended", "éè¯ç»æ"); | 
 |  |  |         }, | 
 |  |  |         confirmed: (e) => { | 
 |  |  |           this.updateCallStatus("connected", "éè¯å·²æ¥é"); | 
 |  |  |         }, | 
 |  |  |       }, | 
 |  |  |       mediaConstraints: { | 
 |  |  |         audio: true, | 
 |  |  |         video: false, | 
 |  |  |       }, | 
 |  |  |       rtcOfferConstraints: { | 
 |  |  |         offerToReceiveAudio: 1, | 
 |  |  |         offerToReceiveVideo: 0, | 
 |  |  |         mandatory: { | 
 |  |  |           OfferToReceiveAudio: true, | 
 |  |  |           OfferToReceiveVideo: false, | 
 |  |  |         }, | 
 |  |  |       }, | 
 |  |  |       pcConfig: { | 
 |  |  |         iceServers: [{ urls: "stun:stun.l.google.com:19302" }], | 
 |  |  |         iceTransportPolicy: "all", | 
 |  |  |         bundlePolicy: "balanced", | 
 |  |  |         rtcpMuxPolicy: "require", | 
 |  |  |         codecs: { | 
 |  |  |           audio: [ | 
 |  |  |             { name: "PCMU", clockRate: 8000, payloadType: 0 }, | 
 |  |  |             { name: "PCMA", clockRate: 8000, payloadType: 8 }, | 
 |  |  |           ], | 
 |  |  |           video: [], | 
 |  |  |         }, | 
 |  |  |       }, | 
 |  |  |     }; | 
 |  |  |  | 
 |  |  |     this.currentSession = this.ua.call( | 
 |  |  |       `sip:${targetNumber}@192.168.100.6`, | 
 |  |  |       options | 
 |  |  |     ); | 
 |  |  |     // å¨ä¼è¯å建å修湠SDP | 
 |  |  |     this.currentSession.on("peerconnection", (pc) => { | 
 |  |  |        this.updateCallStatus('calling', 'å¼å«ä¸...'); | 
 |  |  |       pc.createOffer = (offerOptions) => { | 
 |  |  |         return RTCPeerConnection.prototype.createOffer | 
 |  |  |           .call(pc, offerOptions) | 
 |  |  |           .then((offer) => { | 
 |  |  |             const modifiedSdp = offer.sdp | 
 |  |  |               .replace(/c=IN IP4 192\.168\.100\.10/g, "c=IN IP4 192.168.100.6") | 
 |  |  |               .replace(/m=audio \d+ RTP\/AVP.*/, "m=audio 7078 RTP/AVP 0 8"); | 
 |  |  |             return new RTCSessionDescription({ | 
 |  |  |               type: "offer", | 
 |  |  |               sdp: modifiedSdp, | 
 |  |  |             }); | 
 |  |  |           }); | 
 |  |  |       }; | 
 |  |  |     }); | 
 |  |  |     this.currentSession.on('failed', (e) => { | 
 |  |  |         this.updateCallStatus('failed', `å¼å«å¤±è´¥2: ${e}`); | 
 |  |  |       }); | 
 |  |  |  | 
 |  |  |       this.currentSession.on('ended', () => { | 
 |  |  |         this.updateCallStatus('ended', 'éè¯å·²ç»æ'); | 
 |  |  |       }); | 
 |  |  |  | 
 |  |  |       this.currentSession.on('confirmed', () => { | 
 |  |  |         this.updateCallStatus('connected', 'éè¯å·²æ¥é'); | 
 |  |  |       }); | 
 |  |  |     this.setupAudio(this.currentSession); | 
 |  |  |   } | 
 |  |  |  | 
 |  |  |   normalizeSDP(offer) { | 
 |  |  |     let sdp = offer.sdp; | 
 |  |  |  | 
 |  |  |     // æ ååSDP | 
 |  |  |     sdp = sdp.replace(/c=IN IP4.*\r\n/, "c=IN IP4 0.0.0.0\r\n"); | 
 |  |  |     sdp = sdp.replace( | 
 |  |  |       /m=audio \d+.*\r\n/, | 
 |  |  |       "m=audio 9 UDP/TLS/RTP/SAVPF 0 8\r\n" | 
 |  |  |     ); | 
 |  |  |  | 
 |  |  |     // ç¡®ä¿å
å«åºæ¬ç¼è§£ç å¨ | 
 |  |  |     if (!sdp.includes("PCMU/8000")) sdp += "a=rtpmap:0 PCMU/8000\r\n"; | 
 |  |  |     if (!sdp.includes("PCMA/8000")) sdp += "a=rtpmap:8 PCMA/8000\r\n"; | 
 |  |  |  | 
 |  |  |     // æ·»å å¿
è¦å±æ§ | 
 |  |  |     sdp += "a=rtcp-mux\r\n"; | 
 |  |  |     sdp += "a=sendrecv\r\n"; | 
 |  |  |  | 
 |  |  |     console.log("æ åååçSDP:", sdp); | 
 |  |  |     return new RTCSessionDescription({ | 
 |  |  |       type: offer.type, | 
 |  |  |       sdp: sdp, | 
 |  |  |     }); | 
 |  |  |   } | 
 |  |  |  | 
 |  |  |   handleCallFailure(e, reject) { | 
 |  |  |     if (e.response?.status_code === 422) { | 
 |  |  |       const serverMinSE = e.response.headers["Min-SE"]?.[0]?.raw || "æªç¥"; | 
 |  |  |       console.error(`æå¡å¨è¦æ± Min-SE â¤ ${serverMinSE}ï¼å½å设置: 120`); | 
 |  |  |     } | 
 |  |  |     console.error("å¼å«å¤±è´¥è¯¦æ
:", { | 
 |  |  |       cause: e.cause, | 
 |  |  |       message: e.message, | 
 |  |  |       response: e.response && { | 
 |  |  |         status: e.response.status_code, | 
 |  |  |         reason: e.response.reason_phrase, | 
 |  |  |       }, | 
 |  |  |     }); | 
 |  |  |  | 
 |  |  |     let errorMessage = "å¼å«å¤±è´¥"; | 
 |  |  |     switch (e.cause) { | 
 |  |  |       case "Incompatible SDP": | 
 |  |  |         errorMessage = "åªä½åå失败ï¼è¯·æ£æ¥ç¼è§£ç å¨é
ç½®"; | 
 |  |  |         break; | 
 |  |  |       case "488": | 
 |  |  |       case "606": | 
 |  |  |         errorMessage = "对æ¹è®¾å¤ä¸æ¯æå½ååªä½é
ç½®"; | 
 |  |  |         break; | 
 |  |  |       case "422": | 
 |  |  |         errorMessage = "ä¼è¯åæ°ä¸æ»¡è¶³æå¡å¨è¦æ±"; | 
 |  |  |         break; | 
 |  |  |       default: | 
 |  |  |         errorMessage = `å¼å«å¤±è´¥3: ${e.cause || e.message}`; | 
 |  |  |     } | 
 |  |  |  | 
 |  |  |     this.updateCallStatus("failed55", errorMessage); | 
 |  |  |     reject(new Error(errorMessage)); | 
 |  |  |   } | 
 |  |  |  | 
 |  |  |   setupAudio(session) { | 
 |  |  |     session.connection.addEventListener("addstream", (e) => { | 
 |  |  |       const audioElement = document.getElementById("remoteAudio"); | 
 |  |  | 
 |  |  |       } | 
 |  |  |     }); | 
 |  |  |   } | 
 |  |  |  | 
 |  |  |   // ææå½åéè¯ | 
 |  |  |   endCall() { | 
 |  |  |     if (this.currentSession) { | 
 |  |  |   if (this.currentSession) { | 
 |  |  |       this.currentSession.terminate(); | 
 |  |  |       this.updateCallStatus("ended", "éè¯å·²ç»æ"); | 
 |  |  |       this.updateCallStatus('ended', 'éè¯å·²ç»æ'); | 
 |  |  |       this.currentSession = null; | 
 |  |  |     } | 
 |  |  |   } | 
 |  |  |  | 
 |  |  |   updateStatus(type, text) { | 
 |  |  |     console.log(`SIPç¶ææ´æ°: ${type} - ${text}`); | 
 |  |  |     if (this.onStatusChange) { | 
 |  |  |       this.onStatusChange({ type, text }); | 
 |  |  |     } | 
 |  |  |   } | 
 |  |  |  | 
 |  |  |   // æ°å¢æ¹æ³ï¼æ´æ°éè¯ç¶æ | 
 |  |  |   updateCallStatus(type, text) { | 
 |  |  |     console.log(`éè¯ç¶ææ´æ°: ${type} - ${text}`); | 
 |  |  |     if (this.onCallStatusChange) { | 
 |  |  |       this.onCallStatusChange({ type, text }); | 
 |  |  |     } | 
 |  |  |   } | 
 |  |  |  | 
 |  |  |   handleIncomingCall(session) { | 
 |  |  |     if (session.direction === "incoming") { | 
 |  |  |       console.log("æ¥çµ:", session.remote_identity.uri.toString()); | 
 |  |  |       if (this.onIncomingCall) { | 
 |  |  |         this.onIncomingCall(session); | 
 |  |  |       } | 
 |  |  |     } | 
 |  |  |   } | 
 |  |  | } |