From ba12c46c88c399670fe6f2d50032945ebf9fd319 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Thu, 21 May 2026 13:50:25 +0200 Subject: [PATCH] KI-AGENT: Telefonie und Setup trennen --- frontend/components/MainNav.vue | 5 + frontend/components/TelephonyCallOverlay.vue | 70 ++ frontend/composables/useTelephonySoftphone.js | 592 ++++++++++++ frontend/layouts/default.vue | 1 + frontend/pages/communication/phone-setup.vue | 288 ++++++ frontend/pages/communication/phone.vue | 877 +----------------- 6 files changed, 987 insertions(+), 846 deletions(-) create mode 100644 frontend/components/TelephonyCallOverlay.vue create mode 100644 frontend/composables/useTelephonySoftphone.js create mode 100644 frontend/pages/communication/phone-setup.vue diff --git a/frontend/components/MainNav.vue b/frontend/components/MainNav.vue index aaf3893..e5d9314 100644 --- a/frontend/components/MainNav.vue +++ b/frontend/components/MainNav.vue @@ -85,6 +85,11 @@ const links = computed(() => { to: "/communication/phone", icon: "i-heroicons-phone" }, + { + label: "Telefonie Setup", + to: "/communication/phone-setup", + icon: "i-heroicons-cog-6-tooth" + }, featureEnabled("helpdesk") ? { label: "Helpdesk", to: "/helpdesk", diff --git a/frontend/components/TelephonyCallOverlay.vue b/frontend/components/TelephonyCallOverlay.vue new file mode 100644 index 0000000..d4a1e24 --- /dev/null +++ b/frontend/components/TelephonyCallOverlay.vue @@ -0,0 +1,70 @@ + + + diff --git a/frontend/composables/useTelephonySoftphone.js b/frontend/composables/useTelephonySoftphone.js new file mode 100644 index 0000000..b63fb7c --- /dev/null +++ b/frontend/composables/useTelephonySoftphone.js @@ -0,0 +1,592 @@ +const loading = ref(false) +const statusLoading = ref(false) +const websocketTesting = ref(false) +const config = ref(null) +const status = ref(null) +const websocketResult = ref(null) +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("") + +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?.testAccounts || []).find((account) => account.extension === selectedExtension.value) + || config.value?.testAccounts?.[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 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 attachRemoteAudio(session) + } + + if (state === SessionState.Terminated) { + 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() + + if (!selectedAccount.value && configRes?.testAccounts?.length) { + selectedExtension.value = configRes.testAccounts[0].extension + } + } 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) => { + addSipEvent(`INVITE von ${invitation.remoteIdentity?.uri?.user || "unbekannt"} empfangen`) + incomingCall.value = invitation + setupSession(invitation, "incoming") + 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, + }, + }, + }) + + setupSession(inviter, "outgoing") + await inviter.invite() + } catch (error) { + 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() + } 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 { + stopCallSignaling() + activeSession.value = null + incomingCall.value = null + callState.value = "idle" + cleanupRemoteAudio() + sipStatus.value = sipRegistered.value ? "Registriert" : "Nicht verbunden" + } + } + + return { + loading, + statusLoading, + websocketTesting, + config, + status, + websocketResult, + lastUpdated, + selectedExtension, + dialTarget, + activeSession, + remoteAudio, + sipLoading, + sipRegistered, + sipStatus, + sipError, + incomingCall, + incomingCaller, + callState, + registererState, + sipEvents, + callSignalStatus, + selectedAccount, + canRegisterSip, + canStartCall, + canHangup, + statusColor, + statusIcon, + websocketColor, + loadTelephony, + refreshStatus, + testWebSocket, + registerSip, + stopSip, + startCall, + acceptIncomingCall, + rejectIncomingCall, + hangupCall, + } +} diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 1f354af..af772cf 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -311,6 +311,7 @@ onMounted(() => { + diff --git a/frontend/pages/communication/phone-setup.vue b/frontend/pages/communication/phone-setup.vue new file mode 100644 index 0000000..286ed48 --- /dev/null +++ b/frontend/pages/communication/phone-setup.vue @@ -0,0 +1,288 @@ + + + diff --git a/frontend/pages/communication/phone.vue b/frontend/pages/communication/phone.vue index 53bd080..802daf8 100644 --- a/frontend/pages/communication/phone.vue +++ b/frontend/pages/communication/phone.vue @@ -1,622 +1,33 @@