From 6abc0dd7727a1bff993535fb5e7287f79de97274 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Wed, 20 May 2026 22:33:47 +0200 Subject: [PATCH] =?UTF-8?q?KI-AGENT:=20SIP-Softphone=20f=C3=BCr=20lokale?= =?UTF-8?q?=20Telefonie=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/pages/communication/phone.vue | 426 +++++++++++++++++++++++++ 3 files changed, 437 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a7ff852..c9a5ecd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -83,6 +83,7 @@ "pdf-lib": "^1.17.1", "pinia": "^2.1.7", "sass": "^1.69.7", + "sip.js": "^0.21.2", "socket.io-client": "^4.7.2", "tailwindcss-safe-area-capacitor": "^0.5.1", "tippy.js": "^6.3.7", @@ -19022,6 +19023,15 @@ "url": "https://github.com/steveukx/git-js?sponsor=1" } }, + "node_modules/sip.js": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/sip.js/-/sip.js-0.21.2.tgz", + "integrity": "sha512-tSqTcIgrOd2IhP/rd70JablvAp+fSfLSxO4hGNY6LkWRY1SKygTO7OtJEV/BQb8oIxtMRx0LE7nUF2MaqGbFzA==", + "license": "MIT", + "engines": { + "node": ">=10.0" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0be2b2d..1dbf304 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -96,6 +96,7 @@ "pdf-lib": "^1.17.1", "pinia": "^2.1.7", "sass": "^1.69.7", + "sip.js": "^0.21.2", "socket.io-client": "^4.7.2", "tailwindcss-safe-area-capacitor": "^0.5.1", "tippy.js": "^6.3.7", diff --git a/frontend/pages/communication/phone.vue b/frontend/pages/communication/phone.vue index 1e393b5..f4ca6b2 100644 --- a/frontend/pages/communication/phone.vue +++ b/frontend/pages/communication/phone.vue @@ -9,6 +9,36 @@ 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 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" @@ -110,7 +140,264 @@ const testWebSocket = async () => { websocketTesting.value = false } +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) { + // Browser dürfen Autoplay blockieren; der Call selbst bleibt davon unberührt. + } +} + +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) { + callState.value = "active" + sipStatus.value = "Im Gespräch" + incomingCall.value = null + await attachRemoteAudio(session) + } + + if (state === SessionState.Terminated) { + callState.value = "terminated" + sipStatus.value = sipRegistered.value ? "Registriert" : "Nicht verbunden" + activeSession.value = null + incomingCall.value = null + cleanupRemoteAudio() + } + }) +} + +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 + 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 ua = new sip.UserAgent({ + uri, + displayName: account.displayName, + authorizationUsername: account.extension, + authorizationPassword: account.password, + transportOptions: { + server: config.value.sipWebSocketUrl, + }, + sessionDescriptionHandlerFactoryOptions: { + constraints: { + audio: true, + video: false, + }, + peerConnectionConfiguration: { + iceServers: [], + }, + }, + }) + + ua.delegate = { + onInvite(invitation) { + incomingCall.value = invitation + setupSession(invitation, "incoming") + }, + } + + const reg = new sip.Registerer(ua) + + userAgent.value = ua + registerer.value = reg + + await ua.start() + await reg.register() + + sipRegistered.value = true + sipStatus.value = `Registriert als ${account.extension}` + } 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 + + 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 { + 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 { + activeSession.value = null + incomingCall.value = null + callState.value = "idle" + cleanupRemoteAudio() + sipStatus.value = sipRegistered.value ? "Registriert" : "Nicht verbunden" + } +} + +watch(config, (nextConfig) => { + if (!selectedAccount.value && nextConfig?.testAccounts?.length) { + selectedExtension.value = nextConfig.testAccounts[0].extension + } +}) + onMounted(loadTelephony) + +onBeforeUnmount(() => { + stopSip() +})