| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997 |
- // softPhone.js - WebPhone核心逻辑与配置管理(使用 localStorage 存储,密码混淆)
- // 本模块负责 SIP 注册、音频处理、DTMF、保持/接回等,不包含外呼逻辑(由 ccPhoneBarSocket 实现)
- import * as JsSIP from 'jssip';
- // ========== 回铃音 URL(使用本地音频文件) ==========
- export const RINGBACK_AUDIO_URL = '/assets/voice/ringback.wav';
- // ========== 默认配置常量(IPCC 与 JsSIP 严格分离) ==========
- /** IPCC(呼叫中心)服务器默认配置 */
- export const IPCC_DEFAULTS = {
- SERVER_PROD: 'sip.ylrzcloud.com',
- SERVER_LOCAL: '129.28.164.235',
- PORT_LOCAL: 1081,
- CONNECT_TIMEOUT: 15000,
- HEARTBEAT_INTERVAL: 16
- };
- /** JsSIP(软电话)SIP 默认配置 */
- export const JS_SIP_DEFAULTS = {
- SERVER: 'wss://sip.ylrzcloud.com:8443', // 线上环境
- DOMAIN: 'sip.ylrzcloud.com',
- TRANSPORT: 'wss',
- USER_AGENT: 'JsSIP',
- SESSION_EXPIRES: 180,
- MIN_SESSION_EXPIRES: 90,
- SPEAKER_VOLUME: 0.8,
- MIC_VOLUME: 0.8,
- RECONNECT_INTERVAL: 15,
- RECONNECT_TOTAL_DURATION: 60000
- };
- /**
- * 将字符串转换为 Base64(安全,使用 TextEncoder 替代已弃用的 unescape)
- */
- const toBase64 = (str) => {
- const bytes = new TextEncoder().encode(str);
- return btoa(String.fromCharCode(...bytes));
- };
- /**
- * 从 Base64 解码为字符串(安全,使用 TextDecoder 替代已弃用的 escape)
- */
- const fromBase64 = (b64) => {
- const binary = atob(b64);
- const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
- return new TextDecoder().decode(bytes);
- };
- /**
- * 简单的密码混淆(非加密,仅避免明文暴露)
- * 注意:生产环境应使用服务端认证token或OAuth
- */
- const encodePassword = (pwd) => {
- if (!pwd) return '';
- try {
- const timestamp = Date.now().toString(36);
- const pwdStr = String(pwd);
- const encoded = toBase64(pwdStr);
- return `${timestamp}:${encoded}`;
- } catch (e) {
- try {
- return btoa(String(pwd));
- } catch (fallbackError) {
- return '';
- }
- }
- };
- const decodePassword = (encoded) => {
- if (!encoded) return '';
- try {
- let base64Part = encoded;
- if (encoded.includes(':')) {
- base64Part = encoded.split(':')[1];
- }
- return fromBase64(base64Part);
- } catch (e) {
- return '';
- }
- };
- // 检查麦克风权限
- export async function checkMicrophonePermission() {
- try {
- const permissionStatus = await navigator.permissions.query({ name: 'microphone' });
- if (permissionStatus.state === 'prompt') {
- await navigator.mediaDevices.getUserMedia({ audio: true });
- return true;
- } else if (permissionStatus.state === 'denied') {
- return false;
- } else if (permissionStatus.state === 'granted') {
- return true;
- }
- } catch (err) {
- return false;
- }
- }
- // ---------- ProfileManager(localStorage 安全存储)----------
- export class ProfileManager {
- constructor() {
- this.profile = null;
- this.load();
- }
- load() {
- try {
- const json = localStorage.getItem('WebPhoneProfile');
- if (!json) {
- this.reset();
- } else {
- this.profile = JSON.parse(json);
- if (this.profile.users) {
- Object.keys(this.profile.users).forEach(uid => {
- const user = this.profile.users[uid];
- if (user.password && typeof user.password === 'string') {
- user.password = decodePassword(user.password);
- }
- });
- }
- }
- } catch (error) {
- console.error('[配置] 加载失败,重置为默认值');
- this.reset();
- }
- }
- save() {
- const toStore = JSON.parse(JSON.stringify(this.profile));
- if (toStore.users) {
- Object.keys(toStore.users).forEach(uid => {
- const user = toStore.users[uid];
- if (user.password && typeof user.password === 'string') {
- user.password = encodePassword(user.password);
- }
- });
- }
- localStorage.setItem('WebPhoneProfile', JSON.stringify(toStore));
- }
- reset() {
- this.profile = {
- users: {},
- user: '',
- reconnect: true,
- reconnect_interval: JS_SIP_DEFAULTS.RECONNECT_INTERVAL,
- user_agent: JS_SIP_DEFAULTS.USER_AGENT,
- session_expires: JS_SIP_DEFAULTS.SESSION_EXPIRES,
- min_session_expires: JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES,
- speaker_volume: JS_SIP_DEFAULTS.SPEAKER_VOLUME,
- mic_volume: JS_SIP_DEFAULTS.MIC_VOLUME,
- speaker_paused: false,
- mic_paused: false,
- auto_answer: false,
- stun: false,
- ice_server: ''
- };
- this.save();
- }
- getProfile() {
- return this.profile;
- }
- getSettings() {
- let reconnectInterval = this.profile.reconnect_interval || 15;
- if (reconnectInterval > 1000) {
- reconnectInterval = Math.floor(reconnectInterval / 1000);
- this.profile.reconnect_interval = reconnectInterval;
- this.save();
- }
- const currentUser = this.getCurrentUserProfile() || {};
- const settings = {
- user_agent: this.profile.user_agent,
- session_expires: this.profile.session_expires,
- min_session_expires: this.profile.min_session_expires,
- stun: this.profile.stun,
- ice_server: this.profile.ice_server,
- auto_answer: currentUser.auto_answer !== undefined ? currentUser.auto_answer : this.profile.auto_answer,
- reconnect: this.profile.reconnect,
- reconnect_interval: reconnectInterval
- };
- return settings;
- }
- updateSettings(settings) {
- Object.assign(this.profile, settings);
- this.save();
- }
- resetSettings() {
- this.profile.reconnect = true;
- this.profile.reconnect_interval = JS_SIP_DEFAULTS.RECONNECT_INTERVAL;
- this.profile.user_agent = JS_SIP_DEFAULTS.USER_AGENT;
- this.profile.session_expires = JS_SIP_DEFAULTS.SESSION_EXPIRES;
- this.profile.min_session_expires = JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES;
- this.profile.auto_answer = false;
- this.profile.stun = false;
- this.profile.ice_server = '';
- this.save();
- }
- getCurrentUserProfile() {
- if (!this.profile.users || !this.profile.user) return null;
- return this.profile.users[this.profile.user];
- }
- addUser(profile) {
- if (!profile.user || !profile.domain || !profile.password) {
- throw new Error('登录名、域名和密码为必填项');
- }
- const userId = `${profile.user}@${profile.domain}`;
- if (this.profile.users[userId]) {
- this.profile.users[userId] = {
- ...this.profile.users[userId],
- ...profile,
- user: profile.user,
- domain: profile.domain
- };
- this.profile.user = userId;
- this.save();
- return;
- }
- this.profile.user = userId;
- this.profile.users[userId] = profile;
- this.save();
- }
- updateUser(userId, updatedProfile) {
- if (this.profile.users[userId]) {
- const newUser = updatedProfile.user;
- const newDomain = updatedProfile.domain;
- const newUserId = `${newUser}@${newDomain}`;
- if (newUserId !== userId) {
- // user 或 domain 改变了,删除旧条目并用新 key 创建
- const merged = {
- ...this.profile.users[userId],
- ...updatedProfile,
- user: newUser,
- domain: newDomain
- };
- delete this.profile.users[userId];
- this.profile.users[newUserId] = merged;
- if (this.profile.user === userId) {
- this.profile.user = newUserId;
- }
- } else {
- this.profile.users[userId] = {
- ...this.profile.users[userId],
- ...updatedProfile,
- user: newUser,
- domain: newDomain
- };
- }
- this.save();
- }
- }
- deleteCurrentUser() {
- if (!this.profile.user) return;
- delete this.profile.users[this.profile.user];
- const keys = Object.keys(this.profile.users);
- this.profile.user = keys.length > 0 ? keys[0] : '';
- this.save();
- }
- switchUser(userId) {
- if (this.profile.users[userId]) {
- this.profile.user = userId;
- this.save();
- }
- }
- }
- // ---------- WebPhone 核心类(SIP注册、媒体处理、保持/接回、DTMF、回铃音)----------
- // 外呼功能已移至 Vue 组件中的 ccPhoneBarSocket 实现
- export class WebPhone {
- constructor(profile, settings) {
- this.profile = profile;
- this.settings = settings;
- this.session = null;
- this.ua = null;
- this.call_id = this.randomUUID();
- this.events = {};
- this.sessionCloseTimerId = null;
- this.callTimerId = null;
- this.dtfmTimerId = null;
- this.audioCtx = null;
- this.oscillatorLow = null;
- this.oscillatorHigh = null;
- this.gainNode = null;
- this.reconnectEnabled = settings.reconnect;
- this.reconnectAttempts = 0;
- this.reconnectStartTime = null;
- this.reconnectTotalDuration = JS_SIP_DEFAULTS.RECONNECT_TOTAL_DURATION;
- this.isReconnecting = false;
- this.reconnectTimerId = null;
- this._isHandlingDisconnect = false;
- // 使用 Base64 回铃音
- this.ringbackMedia = new Audio(RINGBACK_AUDIO_URL);
- this.ringbackMedia.loop = true;
- this.remoteMedia = new Audio();
- this.localMedia = new Audio();
- this.peerConnection = null;
- this.localStream = null;
- this.initUA();
- }
- randomUUID() {
- if (crypto && typeof crypto.randomUUID === 'function') {
- return crypto.randomUUID().replace(/-/g, '');
- }
- return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
- const r = Math.random() * 16 | 0;
- const v = c === 'x' ? r : (r & 0x3 | 0x8);
- return v.toString(16);
- });
- }
- applyWebSocketPatch() {
- if (window._webSocketPatchedForSip) return;
- const targetServer = this.profile.server;
- if (!targetServer || !(targetServer.startsWith('ws://') || targetServer.startsWith('wss://'))) {
- console.warn('[WebPhone] WebSocket地址无效:', targetServer);
- return;
- }
- try {
- new URL(targetServer);
- } catch (e) {
- console.error('[WebPhone] WebSocket URL格式错误:', targetServer);
- return;
- }
- const originalWebSocket = window.WebSocket;
- const patchedWebSocket = function(url, protocols) {
- if (url === targetServer && protocols && protocols.length > 0) {
- return new originalWebSocket(url);
- }
- return new originalWebSocket(url, protocols);
- };
- for (let key in originalWebSocket) {
- if (originalWebSocket.hasOwnProperty(key)) {
- patchedWebSocket[key] = originalWebSocket[key];
- }
- }
- window.WebSocket = patchedWebSocket;
- window._webSocketPatchedForSip = true;
- console.log('[WebPhone] WebSocket Patch已应用');
- }
- initUA() {
- if (!this.profile.server || !this.profile.user || !this.profile.domain) {
- console.error('[jsSip] 配置不完整');
- this.emit('OnStatusMessage', { type: 'error', text: '配置不完整' });
- return;
- }
- // JsSIP 3.x 底层创建 WebSocket 时已自动使用 'sip' 子协议(RFC 7118)
- const socket = new JsSIP.WebSocketInterface(this.profile.server);
- // 修复 JsSIP 3.x 的 via_transport 问题:
- // 对于 WSS 连接,JsSIP 设置 via_transport = 'WSS'(WebSocketInterface.js:23)
- // 但 RFC 3261/7118 中 Via header transport 应用 'WS'(不分是否 TLS)
- // 若服务器不识别 'WSS' 会静默丢弃 REGISTER 请求,导致超时
- if (String(this.profile.server || '').startsWith('wss://')) {
- socket.via_transport = 'WS';
- console.log('[jsSip] WSS 连接: 修正 via_transport WSS → WS(RFC 7118)');
- }
- const user = String(this.profile.user || '');
- const displayName = this.profile.display_name ? String(this.profile.display_name) : '';
- const password = this.profile.password ? String(this.profile.password) : '';
- const server = String(this.profile.server || '');
- // SIP over WebSocket 的 transport 始终为 'ws'(RFC 7118),与是否 WSS/TLS 无关
- const transport = 'ws';
- const domain = String(this.profile.domain || '');
- if (!user || !domain || !password) {
- console.error('[jsSip] 账号配置缺失:', { user, domain, hasPassword: !!password });
- this.emit('OnStatusMessage', { type: 'error', text: '账号配置不完整,请检查登录名、域名和密码' });
- return;
- }
- // 检测混合内容问题:HTTP 页面使用 WSS 连接会被浏览器阻止
- if (server.startsWith('wss://') && window.location.protocol === 'http:') {
- console.warn('[jsSip] 警告: 页面通过 HTTP 加载,但 SIP 服务器使用 WSS 协议。浏览器会阻止混合内容,请使用 HTTPS 或改用 WS 协议');
- this.emit('OnStatusMessage', { type: 'warn', text: 'HTTP页面使用WSS会被浏览器阻止' });
- }
- const uri = new JsSIP.URI('sip', user, domain);
- const contactUriStr = `sip:${user}@${domain};transport=${transport}`;
- this.configuration = {
- sockets: [socket],
- authorization_user: user,
- user_agent: this.settings.user_agent || JS_SIP_DEFAULTS.USER_AGENT,
- display_name: displayName || undefined,
- // 启用会话定时器,防止长时间通话被中间代理断开
- session_timers: true,
- session_timers_expires: this.settings.session_expires || JS_SIP_DEFAULTS.SESSION_EXPIRES,
- session_timers_min_se: this.settings.min_session_expires || JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES,
- no_answer_timeout: 60,
- register: true,
- uri: uri.toAor(),
- contact_uri: contactUriStr,
- // 始终使用 password 模式进行认证
- // 移除旧的 ha1 启发式判断(password.length === 32),避免错误地将32位密码当成HA1
- password: password
- };
- console.log(`[jsSip] UA配置完成: ${user}@${domain}, 服务器: ${server}, 认证方式: password`);
- }
- createUA() {
- this.ua = new JsSIP.UA(this.configuration);
- this.ua.set('display_name', this.profile.display_name);
- this.ua.on('connecting', this.connecting.bind(this));
- this.ua.on('connected', this.connected.bind(this));
- this.ua.on('disconnected', this.disconnected.bind(this));
- this.ua.on('registered', this.registered.bind(this));
- this.ua.on('unregistered', this.unregistered.bind(this));
- this.ua.on('registrationFailed', this.registrationFailed.bind(this));
- this.ua.on('registrationExpiring', this.registrationExpiring.bind(this));
- this.ua.on('newRTCSession', this.newRTCSession.bind(this));
- this.ua.on('newMessage', this.newMessage.bind(this));
- this.ua.on('transportError', this.transportError.bind(this));
- }
- On(event, callback) {
- this.events[event] = callback;
- }
- Off(event) {
- if (this.events[event]) {
- delete this.events[event];
- }
- }
- emit(event, ...args) {
- if (this.events[event]) {
- try {
- this.events[event](...args);
- } catch (e) {
- console.error(e);
- }
- }
- }
- resetReconnectState() {
- if (this.reconnectTimerId) {
- clearTimeout(this.reconnectTimerId);
- this.reconnectTimerId = null;
- }
- this.isReconnecting = false;
- this.reconnectAttempts = 0;
- this.reconnectStartTime = null;
- this.emit('OnReconnectStatus', { isReconnecting: false, failed: false });
- }
- Start(reconnect, isReconnect = false) {
- console.log(`[SIP] 启动 ${reconnect ? '启用重连' : '禁用重连'} ${isReconnect ? '(重连模式)' : ''}`);
- this.reconnectEnabled = reconnect;
- if (this.ua) {
- try {
- if (this.ua.isRegistered()) this.ua.unregister();
- this.ua.stop();
- } catch (e) {}
- this.ua = null;
- }
- if (!isReconnect) this.resetReconnectState();
- setTimeout(() => {
- this.createUA();
- if (this.ua.isRegistered()) {
- this.SetQueueIn();
- return;
- }
- if (this.ua.isConnected()) {
- this.Register();
- return;
- }
- try {
- this.ua.start();
- } catch (error) {
- console.error('[SIP] UA启动失败:', error.message);
- this.emit('OnStatusMessage', { type: 'error', text: '启动失败: ' + error.message });
- this.scheduleReconnect();
- }
- }, 50);
- }
- Register() {
- if (this.ua) this.ua.register();
- }
- UnRegister() {
- this.reconnectEnabled = false;
- this.resetReconnectState();
- if (this.ua) {
- try {
- if (this.ua.isRegistered()) this.ua.unregister();
- this.ua.stop();
- } catch (e) {}
- this.ua = null;
- }
- if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
- if (this.callTimerId) clearInterval(this.callTimerId);
- if (this.sessionCloseTimerId) clearTimeout(this.sessionCloseTimerId);
- if (this.dtfmTimerId) clearTimeout(this.dtfmTimerId);
- }
- scheduleReconnect() {
- if (!this.reconnectEnabled) return;
- if (this.reconnectTimerId) {
- clearTimeout(this.reconnectTimerId);
- this.reconnectTimerId = null;
- }
- if (this.isReconnecting) return;
- const now = Date.now();
- if (this.reconnectStartTime === null) this.reconnectStartTime = now;
- const elapsed = now - this.reconnectStartTime;
- if (elapsed >= this.reconnectTotalDuration) {
- console.error('[SIP] 重连超时(超过1分钟)');
- this.isReconnecting = false;
- this.emit('OnReconnectStatus', { isReconnecting: false, failed: true });
- this.emit('OnStatusMessage', { type: 'error', text: '重连超时' });
- return;
- }
- this.reconnectAttempts++;
- let interval = (this.settings.reconnect_interval || 15) * 1000;
- if (this.reconnectAttempts > 5) interval = 10000;
- if (this.reconnectAttempts > 10) interval = 20000;
- if (elapsed + interval > this.reconnectTotalDuration) {
- const remainingTime = this.reconnectTotalDuration - elapsed;
- if (remainingTime < 1000) {
- this.emit('OnReconnectStatus', { isReconnecting: false, failed: true });
- this.emit('OnStatusMessage', { type: 'error', text: '重连超时' });
- return;
- }
- interval = Math.min(interval, remainingTime);
- }
- console.log(`[SIP] ${Math.ceil(interval/1000)}秒后重连 (第${this.reconnectAttempts}次)`);
- this.isReconnecting = true;
- this.emit('OnReconnectStatus', { isReconnecting: true, failed: false });
- this.reconnectTimerId = setTimeout(() => {
- this.isReconnecting = false;
- this.reconnectTimerId = null;
- this.Start(true, true);
- }, interval);
- }
- // ---- JsSIP 事件处理 ----
- connecting() {
- console.log('[jsSip] 连接中...');
- this.emit('OnStatusMessage', { type: 'info', text: 'jsSip连接中...' });
- }
- connected() {
- console.log('[jsSip] 已连接,开始注册');
- this.Register();
- this.emit('OnStatusMessage', { type: 'success', text: 'jsSip开始注册' });
- }
- disconnected(e) {
- if (this._isHandlingDisconnect) return;
- this._isHandlingDisconnect = true;
- const reason = e?.cause || e?.message || '未知原因';
- const code = e?.code || '';
- if (code !== '') {
- console.error(`[SIP] 连接断开: ${reason}`, {
- code,
- cause: e?.cause,
- message: e?.message,
- server: this.profile?.server || 'unknown',
- fullEvent: e
- });
- }
- this.emit('OnRegister', { registered: false });
- this.emit('OnStatusMessage', { type: 'error', text: 'WSS断开: ' + reason });
- if (this.ua) {
- try { this.ua.stop(); } catch (err) {}
- this.ua = null;
- }
- if (!this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
- setTimeout(() => { this._isHandlingDisconnect = false; }, 1000);
- }
- registered() {
- this.resetReconnectState();
- this.emit('OnRegister', { registered: true });
- this.emit('OnStatusMessage', { type: 'success', text: '已连接' });
- this.SetQueueIn();
- }
- unregistered() {
- this.emit('OnRegister', { registered: false });
- this.emit('OnStatusMessage', { type: 'info', text: 'jsSip已注销' });
- }
- registrationFailed(e) {
- const cause = e?.cause || e?.message || '未知原因';
- const statusCode = e?.response?.status_code || '';
- console.error('[jsSip] 注册失败:', {
- cause,
- status_code: statusCode,
- response: e?.response,
- server: this.profile?.server || 'unknown',
- user: this.profile?.user || 'unknown',
- domain: this.profile?.domain || 'unknown'
- });
- // 根据失败原因给出更明确的中文提示
- let errorText = '注册失败: ' + cause;
- if (cause === 'Connection Error') {
- errorText = '注册失败: 无法连接到SIP服务器,请检查服务器地址 ' + (this.profile?.server || '') + ' 是否可访问';
- } else if (cause.includes('403') || cause.includes('Forbidden') || cause.includes('401') || cause.includes('Unauthorized')) {
- errorText = '注册失败: 认证失败(code:' + statusCode + '),请检查分机号和密码是否正确';
- } else if (cause.includes('404') || cause.includes('Not Found')) {
- errorText = '注册失败: 用户不存在(code:404),请检查分机号是否正确';
- } else if (cause.includes('408') || cause.includes('Timeout') || cause.includes('timeout')) {
- errorText = '注册失败: 注册请求超时,服务器无响应';
- }
- this.emit('OnRegister', { registered: false });
- this.emit('OnStatusMessage', { type: 'error', text: errorText });
- if (!this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
- }
- registrationExpiring() {
- console.log('[jsSip] 注册即将过期,重新注册');
- this.Register();
- }
- transportError(err) {
- // 详细输出 WebSocket 错误信息,帮助排查连接问题
- const errorCode = err?.code || '';
- const errorMessage = err?.message || '';
- const errorReason = err?.reason || '';
- console.error('[SIP] WebSocket传输错误:', {
- code: errorCode,
- message: errorMessage,
- reason: errorReason,
- server: this.profile?.server || 'unknown',
- fullError: err
- });
- let detail = errorMessage || errorReason || JSON.stringify(err);
- let errorText = 'WSS连接失败: ' + detail;
- if (errorMessage.includes('SecurityError') || errorMessage.includes('mixed content') || errorMessage.includes('Mixed Content')) {
- errorText = 'WSS连接被浏览器阻止: 页面是HTTP协议,无法连接安全的WSS服务。请使用HTTPS访问页面或改用WS协议';
- } else if (errorCode === 1006 || errorMessage.includes('close with code 1006')) {
- errorText = 'WSS连接异常关闭(code 1006): 服务器无响应或SSL证书错误, 服务地址:' + (this.profile?.server || 'unknown');
- } else if (errorCode === 1005 || errorMessage.includes('close with code 1005')) {
- errorText = 'WSS连接被拒绝(code 1005): 服务器不接受WebSocket连接, 请检查服务地址和端口';
- }
- this.emit('OnStatusMessage', { type: 'error', text: errorText });
- if (!this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
- }
- // 外呼功能由 ccPhoneBarSocket 实现,WebPhone 不再实现 Call 方法
- // 但保留 Answer、Terminate、ToggleHold、ToggleMicPhone 等方法供来电和通话控制
- Answer() {
- if (this.session) this.session.answer({ mediaConstraints: { audio: true, video: false } });
- }
- Terminate(code) {
- const currentSession = this.session;
- if (currentSession) {
- if (code) currentSession.terminate({ status_code: code });
- else currentSession.terminate();
- }
- if (this.sessionCloseTimerId) clearTimeout(this.sessionCloseTimerId);
- // 捕获当前session引用,定时器回调只在session未变时执行清理
- this.sessionCloseTimerId = setTimeout(() => {
- if (this.session === currentSession) this.sessionClosed(true, '');
- }, 1000);
- }
- ToggleHold() {
- if (this.session && this.session.isEstablished()) {
- if (this.session.isOnHold().local) this.session.unhold();
- else this.session.hold();
- }
- }
- ToggleMicPhone() {
- if (this.session) {
- if (this.session.isMuted().audio) this.session.unmute();
- else this.session.mute();
- }
- }
- SetSpeaker(paused, volume) {
- this.remoteMedia.volume = paused ? 0 : volume;
- this.ringbackMedia.volume = paused ? 0 : volume;
- }
- SetMicPhone(paused, volume) {
- this.localMedia.volume = paused ? 0 : volume;
- if (this.localStream) {
- this.localStream.getAudioTracks().forEach((track) => {
- track.enabled = !paused;
- });
- }
- }
- SetQueueIn() {
- if (this.ua && this.ua.isRegistered()) {
- this.ua.sendMessage('execute_available', `${this.profile.user}@${this.profile.domain}`, {});
- }
- }
- SetQueueOut() {
- if (this.ua && this.ua.isRegistered()) {
- this.ua.sendMessage('execute_logout', `${this.profile.user}@${this.profile.domain}`, {});
- }
- }
- SendDTMF(tone) {
- if (this.session && this.session.isEstablished()) this.session.sendDTMF(tone);
- }
- PlayDtmfTone(key) {
- const DTMF_MAP = {
- '0': [697, 1633], '1': [697, 1209], '2': [697, 1336], '3': [697, 1477],
- '4': [770, 1209], '5': [770, 1336], '6': [770, 1477], '7': [852, 1209],
- '8': [852, 1336], '9': [852, 1477], '*': [697, 1633], '#': [770, 1633]
- };
- const [lowFreq, highFreq] = DTMF_MAP[key] || [697, 1209];
- this.generateDtmfTone(lowFreq, highFreq, 0.2, this.remoteMedia.volume);
- }
- generateDtmfTone(lowFreq, highFreq, duration, volume) {
- if (this.dtfmTimerId) {
- clearTimeout(this.dtfmTimerId);
- if (this.oscillatorLow) {
- try { this.oscillatorLow.stop(); } catch (e) {}
- }
- if (this.oscillatorHigh) {
- try { this.oscillatorHigh.stop(); } catch (e) {}
- }
- }
- if (!this.audioCtx || this.audioCtx.state === 'closed') {
- this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
- }
- this.gainNode = this.audioCtx.createGain();
- this.oscillatorLow = this.audioCtx.createOscillator();
- this.oscillatorHigh = this.audioCtx.createOscillator();
- this.oscillatorLow.type = 'sine';
- this.oscillatorLow.frequency.value = lowFreq;
- this.oscillatorHigh.type = 'sine';
- this.oscillatorHigh.frequency.value = highFreq;
- this.oscillatorLow.connect(this.gainNode);
- this.oscillatorHigh.connect(this.gainNode);
- this.gainNode.connect(this.audioCtx.destination);
- this.gainNode.gain.setValueAtTime(volume, this.audioCtx.currentTime);
- this.gainNode.gain.linearRampToValueAtTime(0, this.audioCtx.currentTime + duration);
- this.oscillatorLow.start();
- this.oscillatorHigh.start();
- this.dtfmTimerId = setTimeout(() => {
- if (this.oscillatorLow) {
- try { this.oscillatorLow.stop(); } catch (e) {}
- }
- if (this.oscillatorHigh) {
- try { this.oscillatorHigh.stop(); } catch (e) {}
- }
- this.dtfmTimerId = null;
- }, duration * 1000);
- }
- IsOnHold() {
- return this.session ? this.session.isOnHold().local : false;
- }
- pauseRingback() {
- if (this.ringbackMedia && !this.ringbackMedia.paused) {
- this.ringbackMedia.pause();
- console.log('[音频] 暂停回铃音');
- }
- }
- playRingback() {
- if (this.ringbackMedia && this.ringbackMedia.paused) {
- this.ringbackMedia.currentTime = 0;
- this.ringbackMedia.play().catch(e => console.warn('[音频] 回铃音播放失败'));
- console.log('[音频] 播放回铃音');
- }
- }
- newRTCSession(event) {
- console.log('[通话] 新会话创建');
- if (this.session) { event.session.terminate({ status_code: 486 }); return; }
- // 清除上一次通话可能残留的定时器,防止其回调误清当前session
- if (this.sessionCloseTimerId) { clearTimeout(this.sessionCloseTimerId); this.sessionCloseTimerId = null; }
- this.session = event.session;
- // 捕获当前session引用,避免旧session的异步事件回调误清新session
- const sessionRef = this.session;
- this.session.on('progress', (e) => {
- this.emit('OnRing', {
- outgoing: sessionRef.direction === 'outgoing',
- caller: sessionRef.remote_identity?.uri?.user || '',
- province: e.response?.getHeader('X-Province') || '',
- city: e.response?.getHeader('X-City') || ''
- });
- });
- this.session.on('confirmed', () => {
- this.pauseRingback();
- this.emit('OnAnswered', sessionRef.direction === 'outgoing');
- this.startCallTimer();
- });
- this.session.on('ended', () => {
- if (this.session === sessionRef) this.sessionClosed(true, '');
- });
- this.session.on('failed', (e) => {
- if (this.session === sessionRef) this.sessionClosed(false, e.cause);
- });
- this.session.on('peerconnection', (pc) => {
- if (pc && typeof pc.addTrack === 'function') {
- this.registerRemoteMedia(pc);
- } else if (this.session && this.session.connection && typeof this.session.connection.addTrack === 'function') {
- console.log('[通话] 使用session.connection');
- this.registerRemoteMedia(this.session.connection);
- } else {
- console.error('[通话] 未找到有效的RTCPeerConnection');
- }
- });
- const outgoing = this.session.direction === 'outgoing';
- this.emit('OnSessionCreated', {
- outgoing,
- callee: this.session.remote_identity?.uri?.user || '',
- province: event.request?.getHeader('X-Province') || '',
- city: event.request?.getHeader('X-City') || ''
- });
- if (outgoing) this.playRingback();
- }
- newMessage(event) {
- if (event.message.direction === 'incoming') {
- this.emit('OnMessage', { type: 'request', message: event.request.body });
- }
- }
- registerRemoteMedia(connection) {
- if (!connection || typeof connection.addTrack !== 'function') {
- console.error('[通话] 无效的连接对象');
- return;
- }
- // 保存 peerConnection 引用
- this.peerConnection = connection;
- // 监听远程流
- connection.ontrack = (e) => {
- if (!this.remoteMedia.srcObject) {
- const stream = new MediaStream();
- e.streams[0].getTracks().forEach(track => stream.addTrack(track));
- this.remoteMedia.srcObject = stream;
- this.remoteMedia.play().catch(err => console.warn('[音频] 远程媒体播放失败'));
- this.pauseRingback();
- }
- };
- // 如果已有本地流,先清理
- if (this.localStream) {
- console.log('[音频] 清理旧的本地流');
- this.localStream.getTracks().forEach(track => {
- track.stop();
- if (this.peerConnection) {
- try {
- this.peerConnection.removeTrack(track, this.localStream);
- } catch (err) {
- // ignore
- }
- }
- });
- this.localStream = null;
- }
- // 清理 localMedia 的旧 srcObject
- if (this.localMedia.srcObject) {
- this.localMedia.srcObject = null;
- }
- // 获取并添加本地流
- navigator.mediaDevices.getUserMedia({ audio: true })
- .then(stream => {
- this.localStream = stream;
- this.localMedia.srcObject = stream;
- stream.getTracks().forEach(track => {
- try {
- connection.addTrack(track, stream);
- console.log('[音频] 本地轨道添加成功');
- } catch (err) {
- console.error('[音频] 添加本地轨道失败:', err);
- }
- });
- })
- .catch(err => {
- console.error('[音频] 获取麦克风失败:', err);
- this.emit('OnStatusMessage', { type: 'error', text: '无法获取麦克风权限' });
- });
- }
- sessionClosed(succeed, reason) {
- if (!this.session) return;
- console.log('[通话] 会话关闭,开始清理资源');
- this.session = null;
- if (this.callTimerId) clearInterval(this.callTimerId);
- this.pauseRingback();
- // 清理远程媒体
- if (this.remoteMedia.srcObject) {
- this.remoteMedia.srcObject.getTracks().forEach(t => t.stop());
- this.remoteMedia.srcObject = null;
- }
- // 清理本地媒体
- if (this.localMedia.srcObject) {
- this.localMedia.srcObject.getTracks().forEach(t => t.stop());
- this.localMedia.srcObject = null;
- }
- // 清理本地流引用
- if (this.localStream) {
- this.localStream.getTracks().forEach(t => t.stop());
- this.localStream = null;
- }
- // 清理 peerConnection
- if (this.peerConnection) {
- try {
- this.peerConnection.close();
- } catch (err) {
- // ignore
- }
- this.peerConnection = null;
- }
- this.emit('OnSessionClosed', { succeeded: succeed, reason });
- }
- startCallTimer() {
- if (this.callTimerId) clearInterval(this.callTimerId);
- let seconds = 0;
- this.callTimerId = setInterval(() => {
- seconds++;
- const mins = Math.floor(seconds / 60);
- const secs = seconds % 60;
- this.emit('OnCallTimer', `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`);
- }, 1000);
- }
- destroy() {
- console.log('[销毁] WebPhone实例');
- this.reconnectEnabled = false;
- [this.reconnectTimerId, this.callTimerId, this.sessionCloseTimerId, this.dtfmTimerId].forEach(timerId => {
- if (timerId) clearTimeout(timerId);
- });
- this.UnRegister();
- const cleanupAudio = (audio) => {
- if (!audio) return;
- try {
- audio.pause();
- audio.src = '';
- if (audio.srcObject) {
- audio.srcObject.getTracks().forEach(t => t.stop());
- audio.srcObject = null;
- }
- audio.load();
- } catch (e) {}
- };
- cleanupAudio(this.ringbackMedia);
- cleanupAudio(this.remoteMedia);
- cleanupAudio(this.localMedia);
- this.ringbackMedia = null;
- this.remoteMedia = null;
- this.localMedia = null;
- if (this.audioCtx) {
- try { if (this.audioCtx.state !== 'closed') this.audioCtx.close(); } catch (e) {}
- this.audioCtx = null;
- }
- this.oscillatorLow = null;
- this.oscillatorHigh = null;
- this.gainNode = null;
- this.events = {};
- this.configuration = null;
- this.profile = null;
- this.settings = null;
- this.session = null;
- this.ua = null;
- console.log('[销毁] WebPhone清理完成');
- }
- }
- export default { WebPhone, ProfileManager, checkMicrophonePermission, RINGBACK_AUDIO_URL, IPCC_DEFAULTS, JS_SIP_DEFAULTS };
|