/** * usePhone — WebRTC softphone via SIP.js + 3CX PBX. * * Registers as a SIP UA on the 3CX SBC (WebSocket), * can make outbound calls and receive inbound calls. * * 3CX SIP credentials come from the xAPI: MyUser.AuthID / AuthPassword * WSS endpoint is the 3CX SBC: wss://:443/ws or wss://:5001 * * Usage: * const phone = usePhone() * await phone.register() * await phone.call('+15145551234') * phone.hangup() */ import { ref, computed, onUnmounted } from 'vue' import { UserAgent, Registerer, Inviter, SessionState } from 'sip.js' // ── 3CX Config (loaded from localStorage, set via Settings page) ── const STORAGE_KEY = 'ops-phone-config' const defaults = { pbxUrl: 'https://targopbx.3cx.ca', wssUrl: 'wss://targopbx.3cx.ca/wss', // SBC WebSocket — adjust once SBC is enabled sipDomain: 'targopbx.3cx.ca', // Per-user SIP creds (from 3CX MyUser API) extension: '', authId: '', authPassword: '', displayName: '', } export function getPhoneConfig () { try { return { ...defaults, ...JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') } } catch { return { ...defaults } } } export function savePhoneConfig (cfg) { localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)) } /** * Authenticate to 3CX API and fetch SIP credentials for the user. * Returns { extension, authId, authPassword, displayName } or throws. */ export async function fetch3cxCredentials (username, password) { const cfg = getPhoneConfig() // Step 1: Login to get Bearer token const loginRes = await fetch(cfg.pbxUrl + '/webclient/api/Login/GetAccessToken', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ Username: username, Password: password }), }) const loginData = await loginRes.json() if (loginData.Status !== 'AuthSuccess') { throw new Error('3CX login failed: ' + (loginData.Status || 'Unknown')) } const token = loginData.Token.access_token // Step 2: Get SIP credentials from MyUser const userRes = await fetch(cfg.pbxUrl + '/xapi/v1/MyUser', { headers: { Authorization: 'Bearer ' + token }, }) if (!userRes.ok) throw new Error('Failed to fetch 3CX user: ' + userRes.status) const user = await userRes.json() return { extension: user.Number, authId: user.AuthID, authPassword: user.AuthPassword, displayName: (user.FirstName + ' ' + user.LastName).trim(), token, // keep for API calls } } // ── Singleton state (shared across components) ── const registered = ref(false) const registering = ref(false) const callState = ref('idle') // idle | calling | ringing | active | ended const callDuration = ref(0) const callDirection = ref('') // out | in const callRemote = ref('') // remote party number const callRemoteName = ref('') const callStartTime = ref(null) const error = ref('') const incomingCall = ref(null) // pending incoming session let ua = null let registerer = null let currentSession = null let durationTimer = null let audioElement = null function getAudioElement () { if (!audioElement) { audioElement = document.createElement('audio') audioElement.autoplay = true document.body.appendChild(audioElement) } return audioElement } function startDurationTimer () { callStartTime.value = Date.now() callDuration.value = 0 durationTimer = setInterval(() => { if (callStartTime.value) { callDuration.value = Math.floor((Date.now() - callStartTime.value) / 1000) } }, 1000) } function stopDurationTimer () { if (durationTimer) { clearInterval(durationTimer) durationTimer = null } } function resetCallState () { callState.value = 'idle' callDuration.value = 0 callDirection.value = '' callRemote.value = '' callRemoteName.value = '' callStartTime.value = null incomingCall.value = null currentSession = null stopDurationTimer() } function attachSessionListeners (session) { currentSession = session session.stateChange.addListener((state) => { switch (state) { case SessionState.Establishing: callState.value = 'ringing' break case SessionState.Established: callState.value = 'active' startDurationTimer() // Attach remote audio setupRemoteMedia(session) break case SessionState.Terminated: callState.value = 'ended' stopDurationTimer() cleanupMedia() // Auto-reset after 2s setTimeout(resetCallState, 2000) break } }) } function setupRemoteMedia (session) { const audio = getAudioElement() const pc = session.sessionDescriptionHandler?.peerConnection if (pc) { pc.getReceivers().forEach((receiver) => { if (receiver.track && receiver.track.kind === 'audio') { const stream = new MediaStream([receiver.track]) audio.srcObject = stream } }) } } function cleanupMedia () { if (audioElement) { audioElement.srcObject = null } } export function usePhone () { /** * Register with 3CX SBC via WebSocket SIP. */ async function register () { if (registered.value || registering.value) return registering.value = true error.value = '' const cfg = getPhoneConfig() if (!cfg.extension || !cfg.authId || !cfg.authPassword) { error.value = 'Phone not configured. Go to Settings → Phone.' registering.value = false return } try { const uri = UserAgent.makeURI(`sip:${cfg.extension}@${cfg.sipDomain}`) if (!uri) throw new Error('Invalid SIP URI') ua = new UserAgent({ uri, transportOptions: { server: cfg.wssUrl, }, authorizationUsername: cfg.authId, authorizationPassword: cfg.authPassword, displayName: cfg.displayName || cfg.extension, // Log level: warn in prod logLevel: 'warn', // Handle incoming calls delegate: { onInvite: (invitation) => { callDirection.value = 'in' callRemote.value = invitation.remoteIdentity?.uri?.user || 'Unknown' callRemoteName.value = invitation.remoteIdentity?.displayName || '' callState.value = 'ringing' incomingCall.value = invitation attachSessionListeners(invitation) }, }, }) await ua.start() registerer = new Registerer(ua) await registerer.register() registered.value = true } catch (e) { error.value = 'Registration failed: ' + e.message console.error('[usePhone] register error:', e) } finally { registering.value = false } } /** * Unregister and disconnect. */ async function unregister () { try { if (currentSession) { try { currentSession.bye?.() || currentSession.cancel?.() } catch {} } if (registerer) await registerer.unregister().catch(() => {}) if (ua) await ua.stop().catch(() => {}) } catch {} registered.value = false registering.value = false resetCallState() ua = null registerer = null } /** * Make outbound call. */ async function call (number) { if (!ua || !registered.value) { error.value = 'Phone not registered' return } if (callState.value !== 'idle') { error.value = 'Already in a call' return } error.value = '' const cfg = getPhoneConfig() const target = UserAgent.makeURI(`sip:${number}@${cfg.sipDomain}`) if (!target) { error.value = 'Invalid number' return } callDirection.value = 'out' callRemote.value = number callState.value = 'calling' const inviter = new Inviter(ua, target, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false }, }, }) attachSessionListeners(inviter) try { await inviter.invite() } catch (e) { error.value = 'Call failed: ' + e.message resetCallState() } } /** * Answer incoming call. */ async function answer () { if (!incomingCall.value) return try { await incomingCall.value.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false }, }, }) } catch (e) { error.value = 'Answer failed: ' + e.message } } /** * Reject incoming call. */ function reject () { if (incomingCall.value) { try { incomingCall.value.reject() } catch {} resetCallState() } } /** * Hang up active call. */ function hangup () { if (!currentSession) return try { if (callState.value === 'active') { currentSession.bye() } else { // Ringing or calling — cancel currentSession.cancel?.() || currentSession.reject?.() } } catch { resetCallState() } } /** * Toggle mute. */ const muted = ref(false) function toggleMute () { if (!currentSession) return const pc = currentSession.sessionDescriptionHandler?.peerConnection if (pc) { pc.getSenders().forEach((sender) => { if (sender.track && sender.track.kind === 'audio') { sender.track.enabled = !sender.track.enabled muted.value = !sender.track.enabled } }) } } /** * Toggle hold (re-INVITE with sendonly/recvonly). */ const held = ref(false) async function toggleHold () { // Simplified hold — mute + hold state // Full SIP hold requires re-INVITE, complex with SIP.js held.value = !held.value toggleMute() // mute audio as basic hold } /** * Send DTMF tone. */ function sendDtmf (tone) { if (!currentSession) return try { currentSession.sessionDescriptionHandler?.sendDtmf(tone) } catch { // Fallback: INFO method try { currentSession.info({ requestOptions: { body: { contentDisposition: 'render', contentType: 'application/dtmf-relay', content: `Signal=${tone}\r\nDuration=160`, }, }, }) } catch {} } } const formattedDuration = computed(() => { const m = Math.floor(callDuration.value / 60) const s = callDuration.value % 60 return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` }) onUnmounted(() => { // Don't unregister on unmount — keep registration alive across page navigations // Only cleanup when explicitly called }) return { // State registered, registering, callState, callDuration, formattedDuration, callDirection, callRemote, callRemoteName, incomingCall, error, muted, held, // Actions register, unregister, call, answer, reject, hangup, toggleMute, toggleHold, sendDtmf, } }