softPhone.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997
  1. // softPhone.js - WebPhone核心逻辑与配置管理(使用 localStorage 存储,密码混淆)
  2. // 本模块负责 SIP 注册、音频处理、DTMF、保持/接回等,不包含外呼逻辑(由 ccPhoneBarSocket 实现)
  3. import * as JsSIP from 'jssip';
  4. // ========== 回铃音 URL(使用本地音频文件) ==========
  5. export const RINGBACK_AUDIO_URL = '/assets/voice/ringback.wav';
  6. // ========== 默认配置常量(IPCC 与 JsSIP 严格分离) ==========
  7. /** IPCC(呼叫中心)服务器默认配置 */
  8. export const IPCC_DEFAULTS = {
  9. SERVER_PROD: 'sip.ylrzcloud.com',
  10. SERVER_LOCAL: '129.28.164.235',
  11. PORT_LOCAL: 1081,
  12. CONNECT_TIMEOUT: 15000,
  13. HEARTBEAT_INTERVAL: 16
  14. };
  15. /** JsSIP(软电话)SIP 默认配置 */
  16. export const JS_SIP_DEFAULTS = {
  17. SERVER: 'wss://sip.ylrzcloud.com:8443', // 线上环境
  18. DOMAIN: 'sip.ylrzcloud.com',
  19. TRANSPORT: 'wss',
  20. USER_AGENT: 'JsSIP',
  21. SESSION_EXPIRES: 180,
  22. MIN_SESSION_EXPIRES: 90,
  23. SPEAKER_VOLUME: 0.8,
  24. MIC_VOLUME: 0.8,
  25. RECONNECT_INTERVAL: 15,
  26. RECONNECT_TOTAL_DURATION: 60000
  27. };
  28. /**
  29. * 将字符串转换为 Base64(安全,使用 TextEncoder 替代已弃用的 unescape)
  30. */
  31. const toBase64 = (str) => {
  32. const bytes = new TextEncoder().encode(str);
  33. return btoa(String.fromCharCode(...bytes));
  34. };
  35. /**
  36. * 从 Base64 解码为字符串(安全,使用 TextDecoder 替代已弃用的 escape)
  37. */
  38. const fromBase64 = (b64) => {
  39. const binary = atob(b64);
  40. const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
  41. return new TextDecoder().decode(bytes);
  42. };
  43. /**
  44. * 简单的密码混淆(非加密,仅避免明文暴露)
  45. * 注意:生产环境应使用服务端认证token或OAuth
  46. */
  47. const encodePassword = (pwd) => {
  48. if (!pwd) return '';
  49. try {
  50. const timestamp = Date.now().toString(36);
  51. const pwdStr = String(pwd);
  52. const encoded = toBase64(pwdStr);
  53. return `${timestamp}:${encoded}`;
  54. } catch (e) {
  55. try {
  56. return btoa(String(pwd));
  57. } catch (fallbackError) {
  58. return '';
  59. }
  60. }
  61. };
  62. const decodePassword = (encoded) => {
  63. if (!encoded) return '';
  64. try {
  65. let base64Part = encoded;
  66. if (encoded.includes(':')) {
  67. base64Part = encoded.split(':')[1];
  68. }
  69. return fromBase64(base64Part);
  70. } catch (e) {
  71. return '';
  72. }
  73. };
  74. // 检查麦克风权限
  75. export async function checkMicrophonePermission() {
  76. try {
  77. const permissionStatus = await navigator.permissions.query({ name: 'microphone' });
  78. if (permissionStatus.state === 'prompt') {
  79. await navigator.mediaDevices.getUserMedia({ audio: true });
  80. return true;
  81. } else if (permissionStatus.state === 'denied') {
  82. return false;
  83. } else if (permissionStatus.state === 'granted') {
  84. return true;
  85. }
  86. } catch (err) {
  87. return false;
  88. }
  89. }
  90. // ---------- ProfileManager(localStorage 安全存储)----------
  91. export class ProfileManager {
  92. constructor() {
  93. this.profile = null;
  94. this.load();
  95. }
  96. load() {
  97. try {
  98. const json = localStorage.getItem('WebPhoneProfile');
  99. if (!json) {
  100. this.reset();
  101. } else {
  102. this.profile = JSON.parse(json);
  103. if (this.profile.users) {
  104. Object.keys(this.profile.users).forEach(uid => {
  105. const user = this.profile.users[uid];
  106. if (user.password && typeof user.password === 'string') {
  107. user.password = decodePassword(user.password);
  108. }
  109. });
  110. }
  111. }
  112. } catch (error) {
  113. console.error('[配置] 加载失败,重置为默认值');
  114. this.reset();
  115. }
  116. }
  117. save() {
  118. const toStore = JSON.parse(JSON.stringify(this.profile));
  119. if (toStore.users) {
  120. Object.keys(toStore.users).forEach(uid => {
  121. const user = toStore.users[uid];
  122. if (user.password && typeof user.password === 'string') {
  123. user.password = encodePassword(user.password);
  124. }
  125. });
  126. }
  127. localStorage.setItem('WebPhoneProfile', JSON.stringify(toStore));
  128. }
  129. reset() {
  130. this.profile = {
  131. users: {},
  132. user: '',
  133. reconnect: true,
  134. reconnect_interval: JS_SIP_DEFAULTS.RECONNECT_INTERVAL,
  135. user_agent: JS_SIP_DEFAULTS.USER_AGENT,
  136. session_expires: JS_SIP_DEFAULTS.SESSION_EXPIRES,
  137. min_session_expires: JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES,
  138. speaker_volume: JS_SIP_DEFAULTS.SPEAKER_VOLUME,
  139. mic_volume: JS_SIP_DEFAULTS.MIC_VOLUME,
  140. speaker_paused: false,
  141. mic_paused: false,
  142. auto_answer: false,
  143. stun: false,
  144. ice_server: ''
  145. };
  146. this.save();
  147. }
  148. getProfile() {
  149. return this.profile;
  150. }
  151. getSettings() {
  152. let reconnectInterval = this.profile.reconnect_interval || 15;
  153. if (reconnectInterval > 1000) {
  154. reconnectInterval = Math.floor(reconnectInterval / 1000);
  155. this.profile.reconnect_interval = reconnectInterval;
  156. this.save();
  157. }
  158. const currentUser = this.getCurrentUserProfile() || {};
  159. const settings = {
  160. user_agent: this.profile.user_agent,
  161. session_expires: this.profile.session_expires,
  162. min_session_expires: this.profile.min_session_expires,
  163. stun: this.profile.stun,
  164. ice_server: this.profile.ice_server,
  165. auto_answer: currentUser.auto_answer !== undefined ? currentUser.auto_answer : this.profile.auto_answer,
  166. reconnect: this.profile.reconnect,
  167. reconnect_interval: reconnectInterval
  168. };
  169. return settings;
  170. }
  171. updateSettings(settings) {
  172. Object.assign(this.profile, settings);
  173. this.save();
  174. }
  175. resetSettings() {
  176. this.profile.reconnect = true;
  177. this.profile.reconnect_interval = JS_SIP_DEFAULTS.RECONNECT_INTERVAL;
  178. this.profile.user_agent = JS_SIP_DEFAULTS.USER_AGENT;
  179. this.profile.session_expires = JS_SIP_DEFAULTS.SESSION_EXPIRES;
  180. this.profile.min_session_expires = JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES;
  181. this.profile.auto_answer = false;
  182. this.profile.stun = false;
  183. this.profile.ice_server = '';
  184. this.save();
  185. }
  186. getCurrentUserProfile() {
  187. if (!this.profile.users || !this.profile.user) return null;
  188. return this.profile.users[this.profile.user];
  189. }
  190. addUser(profile) {
  191. if (!profile.user || !profile.domain || !profile.password) {
  192. throw new Error('登录名、域名和密码为必填项');
  193. }
  194. const userId = `${profile.user}@${profile.domain}`;
  195. if (this.profile.users[userId]) {
  196. this.profile.users[userId] = {
  197. ...this.profile.users[userId],
  198. ...profile,
  199. user: profile.user,
  200. domain: profile.domain
  201. };
  202. this.profile.user = userId;
  203. this.save();
  204. return;
  205. }
  206. this.profile.user = userId;
  207. this.profile.users[userId] = profile;
  208. this.save();
  209. }
  210. updateUser(userId, updatedProfile) {
  211. if (this.profile.users[userId]) {
  212. const newUser = updatedProfile.user;
  213. const newDomain = updatedProfile.domain;
  214. const newUserId = `${newUser}@${newDomain}`;
  215. if (newUserId !== userId) {
  216. // user 或 domain 改变了,删除旧条目并用新 key 创建
  217. const merged = {
  218. ...this.profile.users[userId],
  219. ...updatedProfile,
  220. user: newUser,
  221. domain: newDomain
  222. };
  223. delete this.profile.users[userId];
  224. this.profile.users[newUserId] = merged;
  225. if (this.profile.user === userId) {
  226. this.profile.user = newUserId;
  227. }
  228. } else {
  229. this.profile.users[userId] = {
  230. ...this.profile.users[userId],
  231. ...updatedProfile,
  232. user: newUser,
  233. domain: newDomain
  234. };
  235. }
  236. this.save();
  237. }
  238. }
  239. deleteCurrentUser() {
  240. if (!this.profile.user) return;
  241. delete this.profile.users[this.profile.user];
  242. const keys = Object.keys(this.profile.users);
  243. this.profile.user = keys.length > 0 ? keys[0] : '';
  244. this.save();
  245. }
  246. switchUser(userId) {
  247. if (this.profile.users[userId]) {
  248. this.profile.user = userId;
  249. this.save();
  250. }
  251. }
  252. }
  253. // ---------- WebPhone 核心类(SIP注册、媒体处理、保持/接回、DTMF、回铃音)----------
  254. // 外呼功能已移至 Vue 组件中的 ccPhoneBarSocket 实现
  255. export class WebPhone {
  256. constructor(profile, settings) {
  257. this.profile = profile;
  258. this.settings = settings;
  259. this.session = null;
  260. this.ua = null;
  261. this.call_id = this.randomUUID();
  262. this.events = {};
  263. this.sessionCloseTimerId = null;
  264. this.callTimerId = null;
  265. this.dtfmTimerId = null;
  266. this.audioCtx = null;
  267. this.oscillatorLow = null;
  268. this.oscillatorHigh = null;
  269. this.gainNode = null;
  270. this.reconnectEnabled = settings.reconnect;
  271. this.reconnectAttempts = 0;
  272. this.reconnectStartTime = null;
  273. this.reconnectTotalDuration = JS_SIP_DEFAULTS.RECONNECT_TOTAL_DURATION;
  274. this.isReconnecting = false;
  275. this.reconnectTimerId = null;
  276. this._isHandlingDisconnect = false;
  277. // 使用 Base64 回铃音
  278. this.ringbackMedia = new Audio(RINGBACK_AUDIO_URL);
  279. this.ringbackMedia.loop = true;
  280. this.remoteMedia = new Audio();
  281. this.localMedia = new Audio();
  282. this.peerConnection = null;
  283. this.localStream = null;
  284. this.initUA();
  285. }
  286. randomUUID() {
  287. if (crypto && typeof crypto.randomUUID === 'function') {
  288. return crypto.randomUUID().replace(/-/g, '');
  289. }
  290. return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
  291. const r = Math.random() * 16 | 0;
  292. const v = c === 'x' ? r : (r & 0x3 | 0x8);
  293. return v.toString(16);
  294. });
  295. }
  296. applyWebSocketPatch() {
  297. if (window._webSocketPatchedForSip) return;
  298. const targetServer = this.profile.server;
  299. if (!targetServer || !(targetServer.startsWith('ws://') || targetServer.startsWith('wss://'))) {
  300. console.warn('[WebPhone] WebSocket地址无效:', targetServer);
  301. return;
  302. }
  303. try {
  304. new URL(targetServer);
  305. } catch (e) {
  306. console.error('[WebPhone] WebSocket URL格式错误:', targetServer);
  307. return;
  308. }
  309. const originalWebSocket = window.WebSocket;
  310. const patchedWebSocket = function(url, protocols) {
  311. if (url === targetServer && protocols && protocols.length > 0) {
  312. return new originalWebSocket(url);
  313. }
  314. return new originalWebSocket(url, protocols);
  315. };
  316. for (let key in originalWebSocket) {
  317. if (originalWebSocket.hasOwnProperty(key)) {
  318. patchedWebSocket[key] = originalWebSocket[key];
  319. }
  320. }
  321. window.WebSocket = patchedWebSocket;
  322. window._webSocketPatchedForSip = true;
  323. console.log('[WebPhone] WebSocket Patch已应用');
  324. }
  325. initUA() {
  326. if (!this.profile.server || !this.profile.user || !this.profile.domain) {
  327. console.error('[jsSip] 配置不完整');
  328. this.emit('OnStatusMessage', { type: 'error', text: '配置不完整' });
  329. return;
  330. }
  331. // JsSIP 3.x 底层创建 WebSocket 时已自动使用 'sip' 子协议(RFC 7118)
  332. const socket = new JsSIP.WebSocketInterface(this.profile.server);
  333. // 修复 JsSIP 3.x 的 via_transport 问题:
  334. // 对于 WSS 连接,JsSIP 设置 via_transport = 'WSS'(WebSocketInterface.js:23)
  335. // 但 RFC 3261/7118 中 Via header transport 应用 'WS'(不分是否 TLS)
  336. // 若服务器不识别 'WSS' 会静默丢弃 REGISTER 请求,导致超时
  337. if (String(this.profile.server || '').startsWith('wss://')) {
  338. socket.via_transport = 'WS';
  339. console.log('[jsSip] WSS 连接: 修正 via_transport WSS → WS(RFC 7118)');
  340. }
  341. const user = String(this.profile.user || '');
  342. const displayName = this.profile.display_name ? String(this.profile.display_name) : '';
  343. const password = this.profile.password ? String(this.profile.password) : '';
  344. const server = String(this.profile.server || '');
  345. // SIP over WebSocket 的 transport 始终为 'ws'(RFC 7118),与是否 WSS/TLS 无关
  346. const transport = 'ws';
  347. const domain = String(this.profile.domain || '');
  348. if (!user || !domain || !password) {
  349. console.error('[jsSip] 账号配置缺失:', { user, domain, hasPassword: !!password });
  350. this.emit('OnStatusMessage', { type: 'error', text: '账号配置不完整,请检查登录名、域名和密码' });
  351. return;
  352. }
  353. // 检测混合内容问题:HTTP 页面使用 WSS 连接会被浏览器阻止
  354. if (server.startsWith('wss://') && window.location.protocol === 'http:') {
  355. console.warn('[jsSip] 警告: 页面通过 HTTP 加载,但 SIP 服务器使用 WSS 协议。浏览器会阻止混合内容,请使用 HTTPS 或改用 WS 协议');
  356. this.emit('OnStatusMessage', { type: 'warn', text: 'HTTP页面使用WSS会被浏览器阻止' });
  357. }
  358. const uri = new JsSIP.URI('sip', user, domain);
  359. const contactUriStr = `sip:${user}@${domain};transport=${transport}`;
  360. this.configuration = {
  361. sockets: [socket],
  362. authorization_user: user,
  363. user_agent: this.settings.user_agent || JS_SIP_DEFAULTS.USER_AGENT,
  364. display_name: displayName || undefined,
  365. // 启用会话定时器,防止长时间通话被中间代理断开
  366. session_timers: true,
  367. session_timers_expires: this.settings.session_expires || JS_SIP_DEFAULTS.SESSION_EXPIRES,
  368. session_timers_min_se: this.settings.min_session_expires || JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES,
  369. no_answer_timeout: 60,
  370. register: true,
  371. uri: uri.toAor(),
  372. contact_uri: contactUriStr,
  373. // 始终使用 password 模式进行认证
  374. // 移除旧的 ha1 启发式判断(password.length === 32),避免错误地将32位密码当成HA1
  375. password: password
  376. };
  377. console.log(`[jsSip] UA配置完成: ${user}@${domain}, 服务器: ${server}, 认证方式: password`);
  378. }
  379. createUA() {
  380. this.ua = new JsSIP.UA(this.configuration);
  381. this.ua.set('display_name', this.profile.display_name);
  382. this.ua.on('connecting', this.connecting.bind(this));
  383. this.ua.on('connected', this.connected.bind(this));
  384. this.ua.on('disconnected', this.disconnected.bind(this));
  385. this.ua.on('registered', this.registered.bind(this));
  386. this.ua.on('unregistered', this.unregistered.bind(this));
  387. this.ua.on('registrationFailed', this.registrationFailed.bind(this));
  388. this.ua.on('registrationExpiring', this.registrationExpiring.bind(this));
  389. this.ua.on('newRTCSession', this.newRTCSession.bind(this));
  390. this.ua.on('newMessage', this.newMessage.bind(this));
  391. this.ua.on('transportError', this.transportError.bind(this));
  392. }
  393. On(event, callback) {
  394. this.events[event] = callback;
  395. }
  396. Off(event) {
  397. if (this.events[event]) {
  398. delete this.events[event];
  399. }
  400. }
  401. emit(event, ...args) {
  402. if (this.events[event]) {
  403. try {
  404. this.events[event](...args);
  405. } catch (e) {
  406. console.error(e);
  407. }
  408. }
  409. }
  410. resetReconnectState() {
  411. if (this.reconnectTimerId) {
  412. clearTimeout(this.reconnectTimerId);
  413. this.reconnectTimerId = null;
  414. }
  415. this.isReconnecting = false;
  416. this.reconnectAttempts = 0;
  417. this.reconnectStartTime = null;
  418. this.emit('OnReconnectStatus', { isReconnecting: false, failed: false });
  419. }
  420. Start(reconnect, isReconnect = false) {
  421. console.log(`[SIP] 启动 ${reconnect ? '启用重连' : '禁用重连'} ${isReconnect ? '(重连模式)' : ''}`);
  422. this.reconnectEnabled = reconnect;
  423. if (this.ua) {
  424. try {
  425. if (this.ua.isRegistered()) this.ua.unregister();
  426. this.ua.stop();
  427. } catch (e) {}
  428. this.ua = null;
  429. }
  430. if (!isReconnect) this.resetReconnectState();
  431. setTimeout(() => {
  432. this.createUA();
  433. if (this.ua.isRegistered()) {
  434. this.SetQueueIn();
  435. return;
  436. }
  437. if (this.ua.isConnected()) {
  438. this.Register();
  439. return;
  440. }
  441. try {
  442. this.ua.start();
  443. } catch (error) {
  444. console.error('[SIP] UA启动失败:', error.message);
  445. this.emit('OnStatusMessage', { type: 'error', text: '启动失败: ' + error.message });
  446. this.scheduleReconnect();
  447. }
  448. }, 50);
  449. }
  450. Register() {
  451. if (this.ua) this.ua.register();
  452. }
  453. UnRegister() {
  454. this.reconnectEnabled = false;
  455. this.resetReconnectState();
  456. if (this.ua) {
  457. try {
  458. if (this.ua.isRegistered()) this.ua.unregister();
  459. this.ua.stop();
  460. } catch (e) {}
  461. this.ua = null;
  462. }
  463. if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
  464. if (this.callTimerId) clearInterval(this.callTimerId);
  465. if (this.sessionCloseTimerId) clearTimeout(this.sessionCloseTimerId);
  466. if (this.dtfmTimerId) clearTimeout(this.dtfmTimerId);
  467. }
  468. scheduleReconnect() {
  469. if (!this.reconnectEnabled) return;
  470. if (this.reconnectTimerId) {
  471. clearTimeout(this.reconnectTimerId);
  472. this.reconnectTimerId = null;
  473. }
  474. if (this.isReconnecting) return;
  475. const now = Date.now();
  476. if (this.reconnectStartTime === null) this.reconnectStartTime = now;
  477. const elapsed = now - this.reconnectStartTime;
  478. if (elapsed >= this.reconnectTotalDuration) {
  479. console.error('[SIP] 重连超时(超过1分钟)');
  480. this.isReconnecting = false;
  481. this.emit('OnReconnectStatus', { isReconnecting: false, failed: true });
  482. this.emit('OnStatusMessage', { type: 'error', text: '重连超时' });
  483. return;
  484. }
  485. this.reconnectAttempts++;
  486. let interval = (this.settings.reconnect_interval || 15) * 1000;
  487. if (this.reconnectAttempts > 5) interval = 10000;
  488. if (this.reconnectAttempts > 10) interval = 20000;
  489. if (elapsed + interval > this.reconnectTotalDuration) {
  490. const remainingTime = this.reconnectTotalDuration - elapsed;
  491. if (remainingTime < 1000) {
  492. this.emit('OnReconnectStatus', { isReconnecting: false, failed: true });
  493. this.emit('OnStatusMessage', { type: 'error', text: '重连超时' });
  494. return;
  495. }
  496. interval = Math.min(interval, remainingTime);
  497. }
  498. console.log(`[SIP] ${Math.ceil(interval/1000)}秒后重连 (第${this.reconnectAttempts}次)`);
  499. this.isReconnecting = true;
  500. this.emit('OnReconnectStatus', { isReconnecting: true, failed: false });
  501. this.reconnectTimerId = setTimeout(() => {
  502. this.isReconnecting = false;
  503. this.reconnectTimerId = null;
  504. this.Start(true, true);
  505. }, interval);
  506. }
  507. // ---- JsSIP 事件处理 ----
  508. connecting() {
  509. console.log('[jsSip] 连接中...');
  510. this.emit('OnStatusMessage', { type: 'info', text: 'jsSip连接中...' });
  511. }
  512. connected() {
  513. console.log('[jsSip] 已连接,开始注册');
  514. this.Register();
  515. this.emit('OnStatusMessage', { type: 'success', text: 'jsSip开始注册' });
  516. }
  517. disconnected(e) {
  518. if (this._isHandlingDisconnect) return;
  519. this._isHandlingDisconnect = true;
  520. const reason = e?.cause || e?.message || '未知原因';
  521. const code = e?.code || '';
  522. if (code !== '') {
  523. console.error(`[SIP] 连接断开: ${reason}`, {
  524. code,
  525. cause: e?.cause,
  526. message: e?.message,
  527. server: this.profile?.server || 'unknown',
  528. fullEvent: e
  529. });
  530. }
  531. this.emit('OnRegister', { registered: false });
  532. this.emit('OnStatusMessage', { type: 'error', text: 'WSS断开: ' + reason });
  533. if (this.ua) {
  534. try { this.ua.stop(); } catch (err) {}
  535. this.ua = null;
  536. }
  537. if (!this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
  538. setTimeout(() => { this._isHandlingDisconnect = false; }, 1000);
  539. }
  540. registered() {
  541. this.resetReconnectState();
  542. this.emit('OnRegister', { registered: true });
  543. this.emit('OnStatusMessage', { type: 'success', text: '已连接' });
  544. this.SetQueueIn();
  545. }
  546. unregistered() {
  547. this.emit('OnRegister', { registered: false });
  548. this.emit('OnStatusMessage', { type: 'info', text: 'jsSip已注销' });
  549. }
  550. registrationFailed(e) {
  551. const cause = e?.cause || e?.message || '未知原因';
  552. const statusCode = e?.response?.status_code || '';
  553. console.error('[jsSip] 注册失败:', {
  554. cause,
  555. status_code: statusCode,
  556. response: e?.response,
  557. server: this.profile?.server || 'unknown',
  558. user: this.profile?.user || 'unknown',
  559. domain: this.profile?.domain || 'unknown'
  560. });
  561. // 根据失败原因给出更明确的中文提示
  562. let errorText = '注册失败: ' + cause;
  563. if (cause === 'Connection Error') {
  564. errorText = '注册失败: 无法连接到SIP服务器,请检查服务器地址 ' + (this.profile?.server || '') + ' 是否可访问';
  565. } else if (cause.includes('403') || cause.includes('Forbidden') || cause.includes('401') || cause.includes('Unauthorized')) {
  566. errorText = '注册失败: 认证失败(code:' + statusCode + '),请检查分机号和密码是否正确';
  567. } else if (cause.includes('404') || cause.includes('Not Found')) {
  568. errorText = '注册失败: 用户不存在(code:404),请检查分机号是否正确';
  569. } else if (cause.includes('408') || cause.includes('Timeout') || cause.includes('timeout')) {
  570. errorText = '注册失败: 注册请求超时,服务器无响应';
  571. }
  572. this.emit('OnRegister', { registered: false });
  573. this.emit('OnStatusMessage', { type: 'error', text: errorText });
  574. if (!this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
  575. }
  576. registrationExpiring() {
  577. console.log('[jsSip] 注册即将过期,重新注册');
  578. this.Register();
  579. }
  580. transportError(err) {
  581. // 详细输出 WebSocket 错误信息,帮助排查连接问题
  582. const errorCode = err?.code || '';
  583. const errorMessage = err?.message || '';
  584. const errorReason = err?.reason || '';
  585. console.error('[SIP] WebSocket传输错误:', {
  586. code: errorCode,
  587. message: errorMessage,
  588. reason: errorReason,
  589. server: this.profile?.server || 'unknown',
  590. fullError: err
  591. });
  592. let detail = errorMessage || errorReason || JSON.stringify(err);
  593. let errorText = 'WSS连接失败: ' + detail;
  594. if (errorMessage.includes('SecurityError') || errorMessage.includes('mixed content') || errorMessage.includes('Mixed Content')) {
  595. errorText = 'WSS连接被浏览器阻止: 页面是HTTP协议,无法连接安全的WSS服务。请使用HTTPS访问页面或改用WS协议';
  596. } else if (errorCode === 1006 || errorMessage.includes('close with code 1006')) {
  597. errorText = 'WSS连接异常关闭(code 1006): 服务器无响应或SSL证书错误, 服务地址:' + (this.profile?.server || 'unknown');
  598. } else if (errorCode === 1005 || errorMessage.includes('close with code 1005')) {
  599. errorText = 'WSS连接被拒绝(code 1005): 服务器不接受WebSocket连接, 请检查服务地址和端口';
  600. }
  601. this.emit('OnStatusMessage', { type: 'error', text: errorText });
  602. if (!this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
  603. }
  604. // 外呼功能由 ccPhoneBarSocket 实现,WebPhone 不再实现 Call 方法
  605. // 但保留 Answer、Terminate、ToggleHold、ToggleMicPhone 等方法供来电和通话控制
  606. Answer() {
  607. if (this.session) this.session.answer({ mediaConstraints: { audio: true, video: false } });
  608. }
  609. Terminate(code) {
  610. const currentSession = this.session;
  611. if (currentSession) {
  612. if (code) currentSession.terminate({ status_code: code });
  613. else currentSession.terminate();
  614. }
  615. if (this.sessionCloseTimerId) clearTimeout(this.sessionCloseTimerId);
  616. // 捕获当前session引用,定时器回调只在session未变时执行清理
  617. this.sessionCloseTimerId = setTimeout(() => {
  618. if (this.session === currentSession) this.sessionClosed(true, '');
  619. }, 1000);
  620. }
  621. ToggleHold() {
  622. if (this.session && this.session.isEstablished()) {
  623. if (this.session.isOnHold().local) this.session.unhold();
  624. else this.session.hold();
  625. }
  626. }
  627. ToggleMicPhone() {
  628. if (this.session) {
  629. if (this.session.isMuted().audio) this.session.unmute();
  630. else this.session.mute();
  631. }
  632. }
  633. SetSpeaker(paused, volume) {
  634. this.remoteMedia.volume = paused ? 0 : volume;
  635. this.ringbackMedia.volume = paused ? 0 : volume;
  636. }
  637. SetMicPhone(paused, volume) {
  638. this.localMedia.volume = paused ? 0 : volume;
  639. if (this.localStream) {
  640. this.localStream.getAudioTracks().forEach((track) => {
  641. track.enabled = !paused;
  642. });
  643. }
  644. }
  645. SetQueueIn() {
  646. if (this.ua && this.ua.isRegistered()) {
  647. this.ua.sendMessage('execute_available', `${this.profile.user}@${this.profile.domain}`, {});
  648. }
  649. }
  650. SetQueueOut() {
  651. if (this.ua && this.ua.isRegistered()) {
  652. this.ua.sendMessage('execute_logout', `${this.profile.user}@${this.profile.domain}`, {});
  653. }
  654. }
  655. SendDTMF(tone) {
  656. if (this.session && this.session.isEstablished()) this.session.sendDTMF(tone);
  657. }
  658. PlayDtmfTone(key) {
  659. const DTMF_MAP = {
  660. '0': [697, 1633], '1': [697, 1209], '2': [697, 1336], '3': [697, 1477],
  661. '4': [770, 1209], '5': [770, 1336], '6': [770, 1477], '7': [852, 1209],
  662. '8': [852, 1336], '9': [852, 1477], '*': [697, 1633], '#': [770, 1633]
  663. };
  664. const [lowFreq, highFreq] = DTMF_MAP[key] || [697, 1209];
  665. this.generateDtmfTone(lowFreq, highFreq, 0.2, this.remoteMedia.volume);
  666. }
  667. generateDtmfTone(lowFreq, highFreq, duration, volume) {
  668. if (this.dtfmTimerId) {
  669. clearTimeout(this.dtfmTimerId);
  670. if (this.oscillatorLow) {
  671. try { this.oscillatorLow.stop(); } catch (e) {}
  672. }
  673. if (this.oscillatorHigh) {
  674. try { this.oscillatorHigh.stop(); } catch (e) {}
  675. }
  676. }
  677. if (!this.audioCtx || this.audioCtx.state === 'closed') {
  678. this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  679. }
  680. this.gainNode = this.audioCtx.createGain();
  681. this.oscillatorLow = this.audioCtx.createOscillator();
  682. this.oscillatorHigh = this.audioCtx.createOscillator();
  683. this.oscillatorLow.type = 'sine';
  684. this.oscillatorLow.frequency.value = lowFreq;
  685. this.oscillatorHigh.type = 'sine';
  686. this.oscillatorHigh.frequency.value = highFreq;
  687. this.oscillatorLow.connect(this.gainNode);
  688. this.oscillatorHigh.connect(this.gainNode);
  689. this.gainNode.connect(this.audioCtx.destination);
  690. this.gainNode.gain.setValueAtTime(volume, this.audioCtx.currentTime);
  691. this.gainNode.gain.linearRampToValueAtTime(0, this.audioCtx.currentTime + duration);
  692. this.oscillatorLow.start();
  693. this.oscillatorHigh.start();
  694. this.dtfmTimerId = setTimeout(() => {
  695. if (this.oscillatorLow) {
  696. try { this.oscillatorLow.stop(); } catch (e) {}
  697. }
  698. if (this.oscillatorHigh) {
  699. try { this.oscillatorHigh.stop(); } catch (e) {}
  700. }
  701. this.dtfmTimerId = null;
  702. }, duration * 1000);
  703. }
  704. IsOnHold() {
  705. return this.session ? this.session.isOnHold().local : false;
  706. }
  707. pauseRingback() {
  708. if (this.ringbackMedia && !this.ringbackMedia.paused) {
  709. this.ringbackMedia.pause();
  710. console.log('[音频] 暂停回铃音');
  711. }
  712. }
  713. playRingback() {
  714. if (this.ringbackMedia && this.ringbackMedia.paused) {
  715. this.ringbackMedia.currentTime = 0;
  716. this.ringbackMedia.play().catch(e => console.warn('[音频] 回铃音播放失败'));
  717. console.log('[音频] 播放回铃音');
  718. }
  719. }
  720. newRTCSession(event) {
  721. console.log('[通话] 新会话创建');
  722. if (this.session) { event.session.terminate({ status_code: 486 }); return; }
  723. // 清除上一次通话可能残留的定时器,防止其回调误清当前session
  724. if (this.sessionCloseTimerId) { clearTimeout(this.sessionCloseTimerId); this.sessionCloseTimerId = null; }
  725. this.session = event.session;
  726. // 捕获当前session引用,避免旧session的异步事件回调误清新session
  727. const sessionRef = this.session;
  728. this.session.on('progress', (e) => {
  729. this.emit('OnRing', {
  730. outgoing: sessionRef.direction === 'outgoing',
  731. caller: sessionRef.remote_identity?.uri?.user || '',
  732. province: e.response?.getHeader('X-Province') || '',
  733. city: e.response?.getHeader('X-City') || ''
  734. });
  735. });
  736. this.session.on('confirmed', () => {
  737. this.pauseRingback();
  738. this.emit('OnAnswered', sessionRef.direction === 'outgoing');
  739. this.startCallTimer();
  740. });
  741. this.session.on('ended', () => {
  742. if (this.session === sessionRef) this.sessionClosed(true, '');
  743. });
  744. this.session.on('failed', (e) => {
  745. if (this.session === sessionRef) this.sessionClosed(false, e.cause);
  746. });
  747. this.session.on('peerconnection', (pc) => {
  748. if (pc && typeof pc.addTrack === 'function') {
  749. this.registerRemoteMedia(pc);
  750. } else if (this.session && this.session.connection && typeof this.session.connection.addTrack === 'function') {
  751. console.log('[通话] 使用session.connection');
  752. this.registerRemoteMedia(this.session.connection);
  753. } else {
  754. console.error('[通话] 未找到有效的RTCPeerConnection');
  755. }
  756. });
  757. const outgoing = this.session.direction === 'outgoing';
  758. this.emit('OnSessionCreated', {
  759. outgoing,
  760. callee: this.session.remote_identity?.uri?.user || '',
  761. province: event.request?.getHeader('X-Province') || '',
  762. city: event.request?.getHeader('X-City') || ''
  763. });
  764. if (outgoing) this.playRingback();
  765. }
  766. newMessage(event) {
  767. if (event.message.direction === 'incoming') {
  768. this.emit('OnMessage', { type: 'request', message: event.request.body });
  769. }
  770. }
  771. registerRemoteMedia(connection) {
  772. if (!connection || typeof connection.addTrack !== 'function') {
  773. console.error('[通话] 无效的连接对象');
  774. return;
  775. }
  776. // 保存 peerConnection 引用
  777. this.peerConnection = connection;
  778. // 监听远程流
  779. connection.ontrack = (e) => {
  780. if (!this.remoteMedia.srcObject) {
  781. const stream = new MediaStream();
  782. e.streams[0].getTracks().forEach(track => stream.addTrack(track));
  783. this.remoteMedia.srcObject = stream;
  784. this.remoteMedia.play().catch(err => console.warn('[音频] 远程媒体播放失败'));
  785. this.pauseRingback();
  786. }
  787. };
  788. // 如果已有本地流,先清理
  789. if (this.localStream) {
  790. console.log('[音频] 清理旧的本地流');
  791. this.localStream.getTracks().forEach(track => {
  792. track.stop();
  793. if (this.peerConnection) {
  794. try {
  795. this.peerConnection.removeTrack(track, this.localStream);
  796. } catch (err) {
  797. // ignore
  798. }
  799. }
  800. });
  801. this.localStream = null;
  802. }
  803. // 清理 localMedia 的旧 srcObject
  804. if (this.localMedia.srcObject) {
  805. this.localMedia.srcObject = null;
  806. }
  807. // 获取并添加本地流
  808. navigator.mediaDevices.getUserMedia({ audio: true })
  809. .then(stream => {
  810. this.localStream = stream;
  811. this.localMedia.srcObject = stream;
  812. stream.getTracks().forEach(track => {
  813. try {
  814. connection.addTrack(track, stream);
  815. console.log('[音频] 本地轨道添加成功');
  816. } catch (err) {
  817. console.error('[音频] 添加本地轨道失败:', err);
  818. }
  819. });
  820. })
  821. .catch(err => {
  822. console.error('[音频] 获取麦克风失败:', err);
  823. this.emit('OnStatusMessage', { type: 'error', text: '无法获取麦克风权限' });
  824. });
  825. }
  826. sessionClosed(succeed, reason) {
  827. if (!this.session) return;
  828. console.log('[通话] 会话关闭,开始清理资源');
  829. this.session = null;
  830. if (this.callTimerId) clearInterval(this.callTimerId);
  831. this.pauseRingback();
  832. // 清理远程媒体
  833. if (this.remoteMedia.srcObject) {
  834. this.remoteMedia.srcObject.getTracks().forEach(t => t.stop());
  835. this.remoteMedia.srcObject = null;
  836. }
  837. // 清理本地媒体
  838. if (this.localMedia.srcObject) {
  839. this.localMedia.srcObject.getTracks().forEach(t => t.stop());
  840. this.localMedia.srcObject = null;
  841. }
  842. // 清理本地流引用
  843. if (this.localStream) {
  844. this.localStream.getTracks().forEach(t => t.stop());
  845. this.localStream = null;
  846. }
  847. // 清理 peerConnection
  848. if (this.peerConnection) {
  849. try {
  850. this.peerConnection.close();
  851. } catch (err) {
  852. // ignore
  853. }
  854. this.peerConnection = null;
  855. }
  856. this.emit('OnSessionClosed', { succeeded: succeed, reason });
  857. }
  858. startCallTimer() {
  859. if (this.callTimerId) clearInterval(this.callTimerId);
  860. let seconds = 0;
  861. this.callTimerId = setInterval(() => {
  862. seconds++;
  863. const mins = Math.floor(seconds / 60);
  864. const secs = seconds % 60;
  865. this.emit('OnCallTimer', `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`);
  866. }, 1000);
  867. }
  868. destroy() {
  869. console.log('[销毁] WebPhone实例');
  870. this.reconnectEnabled = false;
  871. [this.reconnectTimerId, this.callTimerId, this.sessionCloseTimerId, this.dtfmTimerId].forEach(timerId => {
  872. if (timerId) clearTimeout(timerId);
  873. });
  874. this.UnRegister();
  875. const cleanupAudio = (audio) => {
  876. if (!audio) return;
  877. try {
  878. audio.pause();
  879. audio.src = '';
  880. if (audio.srcObject) {
  881. audio.srcObject.getTracks().forEach(t => t.stop());
  882. audio.srcObject = null;
  883. }
  884. audio.load();
  885. } catch (e) {}
  886. };
  887. cleanupAudio(this.ringbackMedia);
  888. cleanupAudio(this.remoteMedia);
  889. cleanupAudio(this.localMedia);
  890. this.ringbackMedia = null;
  891. this.remoteMedia = null;
  892. this.localMedia = null;
  893. if (this.audioCtx) {
  894. try { if (this.audioCtx.state !== 'closed') this.audioCtx.close(); } catch (e) {}
  895. this.audioCtx = null;
  896. }
  897. this.oscillatorLow = null;
  898. this.oscillatorHigh = null;
  899. this.gainNode = null;
  900. this.events = {};
  901. this.configuration = null;
  902. this.profile = null;
  903. this.settings = null;
  904. this.session = null;
  905. this.ua = null;
  906. console.log('[销毁] WebPhone清理完成');
  907. }
  908. }
  909. export default { WebPhone, ProfileManager, checkMicrophonePermission, RINGBACK_AUDIO_URL, IPCC_DEFAULTS, JS_SIP_DEFAULTS };