const loading = ref(false) const statusLoading = ref(false) const websocketTesting = ref(false) const callHistoryLoading = ref(false) const config = ref(null) const status = ref(null) const websocketResult = ref(null) const callHistory = ref([]) const lastUpdated = ref(null) const selectedExtension = ref("1001") const dialTarget = ref("600") const sipModule = ref(null) const userAgent = shallowRef(null) const registerer = shallowRef(null) const activeSession = shallowRef(null) const remoteAudio = ref(null) const sipLoading = ref(false) const sipRegistered = ref(false) const sipStatus = ref("Nicht verbunden") const sipError = ref(null) const incomingCall = ref(null) const callState = ref("idle") const registererState = ref("Initial") const sipEvents = ref([]) const ringtoneTimer = shallowRef(null) const ringtoneAudioContext = shallowRef(null) const callSignalStatus = ref("Bereit") const originalDocumentTitle = ref("") const activeCallRecordId = ref(null) const activeCallDirection = ref(null) const activeCallAnsweredAt = ref(null) export const useTelephonySoftphone = () => { const toast = useToast() const { $api } = useNuxtApp() const incomingCaller = computed(() => { const identity = incomingCall.value?.remoteIdentity return identity?.displayName || identity?.uri?.user || "Unbekannter Anrufer" }) const selectedAccount = computed(() => (config.value?.accounts || []).find((account) => account.extension === selectedExtension.value) || (config.value?.accounts || [])[0] ) const canRegisterSip = computed(() => Boolean(config.value?.sipWebSocketUrl && config.value?.sipDomain && selectedAccount.value && !sipLoading.value) ) const canStartCall = computed(() => Boolean(sipRegistered.value && dialTarget.value?.trim() && !activeSession.value && !sipLoading.value) ) const canHangup = computed(() => Boolean(activeSession.value && callState.value !== "terminated") ) const statusColor = computed(() => { if (status.value?.reachable) return "success" if (!status.value?.enabled) return "warning" return "error" }) const statusIcon = computed(() => { if (status.value?.reachable) return "i-heroicons-signal" if (!status.value?.enabled) return "i-heroicons-pause-circle" return "i-heroicons-signal-slash" }) const websocketColor = computed(() => { if (!websocketResult.value) return "neutral" return websocketResult.value.ok ? "success" : "error" }) const addSipEvent = (message) => { sipEvents.value = [ { time: new Date().toLocaleTimeString("de-DE"), message, }, ...sipEvents.value, ].slice(0, 8) } const sipCallIdFromSession = (session) => session?.request?.callId || session?.request?.message?.callId || session?.incomingInviteRequest?.message?.callId || session?.outgoingInviteRequest?.message?.callId || null const loadCallHistory = async () => { callHistoryLoading.value = true try { callHistory.value = await $api("/api/telephony/calls?limit=20") } catch (error) { addSipEvent(`Anrufhistorie konnte nicht geladen werden: ${error?.message || "Unbekannter Fehler"}`) } finally { callHistoryLoading.value = false } } const createCallRecord = async ({ direction, status: nextStatus, localExtension, remoteNumber, remoteDisplayName, session }) => { try { const call = await $api("/api/telephony/calls", { method: "POST", body: { direction, status: nextStatus, localExtension, remoteNumber, remoteDisplayName, sipCallId: sipCallIdFromSession(session), startedAt: new Date().toISOString(), }, }) activeCallRecordId.value = call.id activeCallDirection.value = direction activeCallAnsweredAt.value = null await loadCallHistory() return call } catch (error) { addSipEvent(`Anrufhistorie konnte nicht geschrieben werden: ${error?.message || "Unbekannter Fehler"}`) return null } } const updateCallRecord = async (id, payload) => { if (!id) return null try { const call = await $api(`/api/telephony/calls/${id}`, { method: "PATCH", body: payload, }) await loadCallHistory() return call } catch (error) { addSipEvent(`Anrufhistorie konnte nicht aktualisiert werden: ${error?.message || "Unbekannter Fehler"}`) return null } } const markCallAnswered = async () => { if (!activeCallRecordId.value || activeCallAnsweredAt.value) return activeCallAnsweredAt.value = new Date() await updateCallRecord(activeCallRecordId.value, { status: "active", answeredAt: activeCallAnsweredAt.value.toISOString(), }) } const finalizeCallRecord = async (statusOverride = null) => { if (!activeCallRecordId.value) return const id = activeCallRecordId.value const direction = activeCallDirection.value const answeredAt = activeCallAnsweredAt.value const endedAt = new Date() const finalStatus = statusOverride || (answeredAt ? "completed" : direction === "incoming" ? "missed" : "canceled") activeCallRecordId.value = null activeCallDirection.value = null activeCallAnsweredAt.value = null await updateCallRecord(id, { status: finalStatus, endedAt: endedAt.toISOString(), }) } const ensureSipModule = async () => { if (!sipModule.value) { sipModule.value = await import("sip.js") } return sipModule.value } const cleanupRemoteAudio = () => { if (!remoteAudio.value?.srcObject) return const stream = remoteAudio.value.srcObject if (stream?.getTracks) { stream.getTracks().forEach((track) => track.stop()) } remoteAudio.value.srcObject = null } const attachRemoteAudio = async (session) => { const peerConnection = session?.sessionDescriptionHandler?.peerConnection if (!peerConnection || !remoteAudio.value) return const remoteStream = new MediaStream() peerConnection.getReceivers().forEach((receiver) => { if (receiver.track) remoteStream.addTrack(receiver.track) }) remoteAudio.value.srcObject = remoteStream try { await remoteAudio.value.play() } catch (error) { addSipEvent(`Audioausgabe blockiert: ${error?.message || "Browser-Autoplay"}`) } } const playRingtoneTick = async () => { if (!window.AudioContext && !window.webkitAudioContext) return const AudioContextConstructor = window.AudioContext || window.webkitAudioContext const context = ringtoneAudioContext.value || new AudioContextConstructor() ringtoneAudioContext.value = context if (context.state === "suspended") { await context.resume() } const oscillator = context.createOscillator() const gain = context.createGain() oscillator.type = "sine" oscillator.frequency.value = 880 gain.gain.setValueAtTime(0.0001, context.currentTime) gain.gain.exponentialRampToValueAtTime(0.12, context.currentTime + 0.03) gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.35) oscillator.connect(gain) gain.connect(context.destination) oscillator.start() oscillator.stop(context.currentTime + 0.38) } const stopCallSignaling = () => { if (ringtoneTimer.value) { window.clearInterval(ringtoneTimer.value) ringtoneTimer.value = null } if (originalDocumentTitle.value) { document.title = originalDocumentTitle.value originalDocumentTitle.value = "" } if (navigator.vibrate) { navigator.vibrate(0) } callSignalStatus.value = "Bereit" } const startCallSignaling = async () => { stopCallSignaling() callSignalStatus.value = "Signalisiert" originalDocumentTitle.value = document.title document.title = "Eingehender Anruf - FEDEO" if (navigator.vibrate) { navigator.vibrate([180, 90, 180]) } try { await playRingtoneTick() ringtoneTimer.value = window.setInterval(() => { playRingtoneTick().catch((error) => { callSignalStatus.value = "Klingelton blockiert" addSipEvent(`Klingelton blockiert: ${error?.message || "Browser-Autoplay"}`) }) }, 1400) } catch (error) { callSignalStatus.value = "Klingelton blockiert" addSipEvent(`Klingelton blockiert: ${error?.message || "Browser-Autoplay"}`) } } const setupSession = (session, direction = "outgoing") => { const { SessionState } = sipModule.value activeSession.value = session callState.value = direction === "incoming" ? "incoming" : "connecting" sipStatus.value = direction === "incoming" ? "Eingehender Anruf" : "Anruf wird aufgebaut" session.stateChange.addListener(async (state) => { if (state === SessionState.Establishing) { callState.value = "connecting" sipStatus.value = "Anruf wird aufgebaut" } if (state === SessionState.Established) { stopCallSignaling() callState.value = "active" sipStatus.value = "Im Gespräch" incomingCall.value = null await markCallAnswered() await attachRemoteAudio(session) } if (state === SessionState.Terminated) { await finalizeCallRecord() stopCallSignaling() callState.value = "terminated" sipStatus.value = sipRegistered.value ? "Registriert" : "Nicht verbunden" activeSession.value = null incomingCall.value = null cleanupRemoteAudio() } }) } const loadTelephony = async () => { loading.value = true try { const [configRes, statusRes] = await Promise.all([ $api("/api/telephony/config"), $api("/api/telephony/status") ]) config.value = configRes status.value = statusRes lastUpdated.value = new Date() const accounts = configRes?.accounts || [] if (!selectedAccount.value && accounts.length) { selectedExtension.value = accounts[0].extension } await loadCallHistory() } catch (error) { toast.add({ title: "Telefonie-Status konnte nicht geladen werden", color: "error" }) } finally { loading.value = false } } const refreshStatus = async () => { statusLoading.value = true try { status.value = await $api("/api/telephony/status") lastUpdated.value = new Date() } catch (error) { toast.add({ title: "Asterisk-Status konnte nicht geprüft werden", color: "error" }) } finally { statusLoading.value = false } } const testWebSocket = async () => { if (!config.value?.sipWebSocketUrl || websocketTesting.value) return websocketTesting.value = true websocketResult.value = null await new Promise((resolve) => { let settled = false const socket = new WebSocket(config.value.sipWebSocketUrl, "sip") const timer = window.setTimeout(() => { if (settled) return settled = true socket.close() websocketResult.value = { ok: false, message: "WebSocket-Verbindung ist abgelaufen." } resolve() }, 3000) socket.onopen = () => { if (settled) return settled = true window.clearTimeout(timer) websocketResult.value = { ok: true, message: "SIP-WebSocket ist aus dem Browser erreichbar." } socket.close() resolve() } socket.onerror = () => { if (settled) return settled = true window.clearTimeout(timer) websocketResult.value = { ok: false, message: "SIP-WebSocket konnte nicht geöffnet werden." } resolve() } }) websocketTesting.value = false } const stopSip = async () => { sipLoading.value = true sipError.value = null try { if (activeSession.value) { await hangupCall() } if (registerer.value) { await registerer.value.unregister() registerer.value = null } if (userAgent.value) { await userAgent.value.stop() userAgent.value = null } sipRegistered.value = false registererState.value = "Initial" sipStatus.value = "Nicht verbunden" callState.value = "idle" } catch (error) { sipError.value = error?.message || "SIP-Verbindung konnte nicht getrennt werden." } finally { sipLoading.value = false } } const registerSip = async () => { if (!canRegisterSip.value) return sipLoading.value = true sipError.value = null try { await stopSip() const sip = await ensureSipModule() const account = selectedAccount.value const uri = sip.UserAgent.makeURI(`sip:${account.extension}@${config.value.sipDomain}`) if (!uri) throw new Error("SIP-URI konnte nicht erstellt werden.") const handleIncomingInvite = (invitation) => { const remoteNumber = invitation.remoteIdentity?.uri?.user || null addSipEvent(`INVITE von ${invitation.remoteIdentity?.uri?.user || "unbekannt"} empfangen`) incomingCall.value = invitation setupSession(invitation, "incoming") void createCallRecord({ direction: "incoming", status: "ringing", localExtension: account.extension, remoteNumber, remoteDisplayName: invitation.remoteIdentity?.displayName || remoteNumber, session: invitation, }) startCallSignaling() toast.add({ title: "Eingehender Anruf", description: `Anruf für ${account.extension}`, color: "primary" }) } const ua = new sip.UserAgent({ uri, displayName: account.displayName, contactName: account.extension, authorizationUsername: account.extension, authorizationPassword: account.password, logBuiltinEnabled: true, logLevel: "log", logConnector: (level, category, label, content) => { if (category === "sip.Transport" && content.includes("Received WebSocket")) { addSipEvent("SIP-Nachricht über WebSocket empfangen") } if (content.includes("INVITE")) { addSipEvent(`${category}: ${content.split("\n")[0]}`) } if (level === "error" || level === "warn") { addSipEvent(`${category}: ${content.split("\n")[0]}`) } }, delegate: { onInvite: handleIncomingInvite, onConnect: () => { addSipEvent("SIP-WebSocket verbunden") sipStatus.value = sipRegistered.value ? `Registriert als ${account.extension}` : "SIP-WebSocket verbunden" }, onDisconnect: () => { addSipEvent("SIP-WebSocket getrennt") sipRegistered.value = false sipStatus.value = "SIP-WebSocket getrennt" }, }, transportOptions: { server: config.value.sipWebSocketUrl, keepAliveInterval: 20, traceSip: true, }, sessionDescriptionHandlerFactoryOptions: { constraints: { audio: true, video: false, }, peerConnectionConfiguration: { iceServers: [], }, }, }) const reg = new sip.Registerer(ua, { expires: 120, }) reg.stateChange.addListener((state) => { registererState.value = state if (state === sip.RegistererState.Registered) { addSipEvent(`Registrierung ${account.extension}: Registered`) sipRegistered.value = true sipStatus.value = `Registriert als ${account.extension}` } if (state === sip.RegistererState.Unregistered || state === sip.RegistererState.Terminated) { addSipEvent(`Registrierung ${account.extension}: ${state}`) sipRegistered.value = false sipStatus.value = "Nicht verbunden" } }) userAgent.value = ua registerer.value = reg await ua.start() await reg.register({ requestDelegate: { onAccept: () => { addSipEvent(`REGISTER ${account.extension}: 200 OK`) sipRegistered.value = true sipStatus.value = `Registriert als ${account.extension}` registererState.value = sip.RegistererState.Registered }, onReject: (response) => { sipRegistered.value = false sipStatus.value = "Registrierung abgelehnt" sipError.value = `Asterisk hat REGISTER mit HTTP/SIP ${response.message.statusCode} abgelehnt.` addSipEvent(`REGISTER ${account.extension}: ${response.message.statusCode}`) }, }, }) } catch (error) { sipRegistered.value = false sipStatus.value = "Nicht verbunden" sipError.value = error?.message || "SIP-Registrierung fehlgeschlagen." } finally { sipLoading.value = false } } const startCall = async () => { if (!canStartCall.value) return sipLoading.value = true sipError.value = null try { const sip = await ensureSipModule() const target = sip.UserAgent.makeURI(`sip:${dialTarget.value.trim()}@${config.value.sipDomain}`) if (!target) throw new Error("Zielnummer ist keine gültige SIP-Adresse.") const inviter = new sip.Inviter(userAgent.value, target, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false, }, }, }) await createCallRecord({ direction: "outgoing", status: "dialing", localExtension: selectedAccount.value?.extension || selectedExtension.value, remoteNumber: dialTarget.value.trim(), remoteDisplayName: dialTarget.value.trim(), session: inviter, }) setupSession(inviter, "outgoing") await inviter.invite() } catch (error) { await finalizeCallRecord("failed") activeSession.value = null callState.value = "idle" sipError.value = error?.message || "Anruf konnte nicht gestartet werden." } finally { sipLoading.value = false } } const acceptIncomingCall = async () => { if (!incomingCall.value) return sipLoading.value = true sipError.value = null stopCallSignaling() try { await incomingCall.value.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false, }, }, }) } catch (error) { sipError.value = error?.message || "Anruf konnte nicht angenommen werden." } finally { sipLoading.value = false } } const rejectIncomingCall = async () => { if (!incomingCall.value) return try { stopCallSignaling() await incomingCall.value.reject() await finalizeCallRecord("rejected") } finally { incomingCall.value = null activeSession.value = null callState.value = "idle" } } const hangupCall = async () => { const session = activeSession.value if (!session) return const { SessionState } = sipModule.value || await ensureSipModule() try { if (session.state === SessionState.Initial && session.reject) { await session.reject() } else if (session.state === SessionState.Establishing && session.cancel) { await session.cancel() } else if (session.state === SessionState.Established && session.bye) { await session.bye() } else if (session.dispose) { session.dispose() } } finally { await finalizeCallRecord() stopCallSignaling() activeSession.value = null incomingCall.value = null callState.value = "idle" cleanupRemoteAudio() sipStatus.value = sipRegistered.value ? "Registriert" : "Nicht verbunden" } } return { loading, statusLoading, websocketTesting, callHistoryLoading, config, status, websocketResult, callHistory, lastUpdated, selectedExtension, dialTarget, activeSession, remoteAudio, sipLoading, sipRegistered, sipStatus, sipError, incomingCall, incomingCaller, callState, registererState, sipEvents, callSignalStatus, selectedAccount, canRegisterSip, canStartCall, canHangup, statusColor, statusIcon, websocketColor, loadTelephony, loadCallHistory, refreshStatus, testWebSocket, registerSip, stopSip, startCall, acceptIncomingCall, rejectIncomingCall, hangupCall, } }