diff --git a/backend/src/routes/telephony.ts b/backend/src/routes/telephony.ts index a8ad27d..ca907ec 100644 --- a/backend/src/routes/telephony.ts +++ b/backend/src/routes/telephony.ts @@ -64,11 +64,34 @@ const testAccounts = () => [ }, ] +const trunkProviders = { + telekom: { + key: "telekom", + label: "Telekom", + defaultRegistrar: "tel.t-online.de", + aorContactIncludesUser: false, + }, + easybell: { + key: "easybell", + label: "Easybell", + defaultRegistrar: "voip.easybell.de", + aorContactIncludesUser: true, + }, +} as const + +type TrunkProviderKey = keyof typeof trunkProviders + +const normalizeTrunkProvider = (provider: string | null | undefined): TrunkProviderKey => + provider === "easybell" ? "easybell" : "telekom" + +const trunkProviderConfig = (provider: string | null | undefined) => + trunkProviders[normalizeTrunkProvider(provider)] + const sanitizeTrunk = (trunk: any) => ({ id: trunk?.id || null, - provider: trunk?.provider || "telekom", + provider: normalizeTrunkProvider(trunk?.provider), enabled: Boolean(trunk?.enabled), - registrar: trunk?.registrar || "tel.t-online.de", + registrar: trunk?.registrar || trunkProviderConfig(trunk?.provider).defaultRegistrar, sipUser: trunk?.sipUser || "", authUser: trunk?.authUser || "", passwordConfigured: Boolean(trunk?.password), @@ -146,45 +169,51 @@ const durationSeconds = (startedAt?: Date | null, endedAt?: Date | null) => { const asteriskValue = (value: string | null | undefined) => String(value || "").replace(/[\r\n]/g, "").trim() -const renderTelekomPjsipConfig = (trunk: any) => { +const renderProviderPjsipConfig = (trunk: any) => { + const provider = trunkProviderConfig(trunk?.provider) + if (!trunk?.enabled) { return [ "; Von FEDEO generiert.", - "; Telekom-Trunk ist deaktiviert.", + `; ${provider.label}-Trunk ist deaktiviert.`, "", ].join("\n") } - const registrar = asteriskValue(trunk.registrar) || "tel.t-online.de" + const providerKey = provider.key + const registrar = asteriskValue(trunk.registrar) || provider.defaultRegistrar const sipUser = asteriskValue(trunk.sipUser) const authUser = asteriskValue(trunk.authUser) || sipUser const password = asteriskValue(trunk.password) const callerId = asteriskValue(trunk.callerId) || sipUser const externalMediaAddress = asteriskValue(trunk.externalMediaAddress || trunk.externalSignalingAddress) + const aorContact = provider.aorContactIncludesUser && sipUser + ? `sip:${sipUser}@${registrar}` + : `sip:${registrar}` return [ "; Von FEDEO generiert. Änderungen im Container können überschrieben werden.", - "[telekom-auth]", + `[${providerKey}-auth]`, "type=auth", "auth_type=userpass", `username=${authUser}`, `password=${password}`, "", - "[telekom-aor]", + `[${providerKey}-aor]`, "type=aor", - `contact=sip:${registrar}`, + `contact=${aorContact}`, "", - "[telekom]", + `[${providerKey}]`, "type=endpoint", "transport=transport-udp", - "context=from-telekom", + `context=from-${providerKey}`, "disallow=all", "allow=alaw,ulaw", - "aors=telekom-aor", - "outbound_auth=telekom-auth", + `aors=${providerKey}-aor`, + `outbound_auth=${providerKey}-auth`, `from_user=${sipUser}`, `from_domain=${registrar}`, - `callerid=Telekom <${callerId}>`, + `callerid=${provider.label} <${callerId}>`, ...(externalMediaAddress ? [`media_address=${externalMediaAddress}`] : []), "direct_media=no", "force_rport=yes", @@ -192,15 +221,15 @@ const renderTelekomPjsipConfig = (trunk: any) => { "rtp_symmetric=yes", "timers=no", "", - "[telekom-identify]", + `[${providerKey}-identify]`, "type=identify", - "endpoint=telekom", + `endpoint=${providerKey}`, `match=${registrar}`, "", - "[telekom-registration]", + `[${providerKey}-registration]`, "type=registration", "transport=transport-udp", - "outbound_auth=telekom-auth", + `outbound_auth=${providerKey}-auth`, `server_uri=sip:${registrar}`, `client_uri=sip:${sipUser}@${registrar}`, `contact_user=${sipUser}`, @@ -208,20 +237,23 @@ const renderTelekomPjsipConfig = (trunk: any) => { "forbidden_retry_interval=300", "expiration=480", "line=yes", - "endpoint=telekom", + `endpoint=${providerKey}`, "", ].join("\n") } -const renderTelekomExtensionsConfig = (trunk: any) => { +const renderProviderExtensionsConfig = (trunk: any) => { + const provider = trunkProviderConfig(trunk?.provider) + if (!trunk?.enabled) { return [ "; Von FEDEO generiert.", - "; Telekom-Routing ist deaktiviert.", + `; ${provider.label}-Routing ist deaktiviert.`, "", ].join("\n") } + const providerKey = provider.key const inboundExtension = asteriskValue(trunk.inboundExtension) || "1001" const outboundPrefix = asteriskValue(trunk.outboundPrefix) || "0" const escapedPrefix = outboundPrefix.replace(/[^0-9*#+]/g, "") @@ -231,23 +263,23 @@ const renderTelekomExtensionsConfig = (trunk: any) => { "; Von FEDEO generiert. Änderungen im Container können überschrieben werden.", "[fedeo-local]", escapedPrefix - ? `exten => _${escapedPrefix}X.,1,NoOp(FEDEO ausgehend über Telekom: $` + "{EXTEN})" - : "exten => _X.,1,NoOp(FEDEO ausgehend über Telekom: ${EXTEN})", + ? `exten => _${escapedPrefix}X.,1,NoOp(FEDEO ausgehend über ${provider.label}: $` + "{EXTEN})" + : `exten => _X.,1,NoOp(FEDEO ausgehend über ${provider.label}: $` + "{EXTEN})", ` same => n,Set(CALLERID(num)=${callerId})`, - " same => n,Dial(PJSIP/${EXTEN}@telekom,60)", + ` same => n,Dial(PJSIP/$` + `{EXTEN}@${providerKey},60)`, " same => n,Hangup()", "", - "exten => _+X.,1,NoOp(FEDEO ausgehend über Telekom: ${EXTEN})", + `exten => _+X.,1,NoOp(FEDEO ausgehend über ${provider.label}: $` + "{EXTEN})", ` same => n,Set(CALLERID(num)=${callerId})`, - " same => n,Dial(PJSIP/${EXTEN}@telekom,60)", + ` same => n,Dial(PJSIP/$` + `{EXTEN}@${providerKey},60)`, " same => n,Hangup()", "", - "[from-telekom]", - "exten => s,1,NoOp(FEDEO eingehend über Telekom)", + `[from-${providerKey}]`, + `exten => s,1,NoOp(FEDEO eingehend über ${provider.label})`, ` same => n,Dial(PJSIP/${inboundExtension},30)`, " same => n,Hangup()", "", - "exten => _X!,1,NoOp(FEDEO eingehend über Telekom: ${EXTEN})", + `exten => _X!,1,NoOp(FEDEO eingehend über ${provider.label}: $` + "{EXTEN})", ` same => n,Dial(PJSIP/${inboundExtension},30)`, " same => n,Hangup()", "", @@ -290,11 +322,11 @@ const writeAsteriskTrunkConfig = async (trunk: any) => { const files = [ { name: "pjsip.telekom.conf", - content: renderTelekomPjsipConfig(trunk), + content: renderProviderPjsipConfig(trunk), }, { name: "extensions.telekom.conf", - content: renderTelekomExtensionsConfig(trunk), + content: renderProviderExtensionsConfig(trunk), }, { name: "pjsip.transport.conf", @@ -351,11 +383,12 @@ const runAsteriskAmiCommand = async (command: string, timeoutMs = 5000) => { }) } -const runAsteriskReload = async () => { +const runAsteriskReload = async (trunk: any) => { + const provider = trunkProviderConfig(trunk?.provider) const commands = [ "module reload res_pjsip.so", "dialplan reload", - "pjsip send register telekom-registration", + `pjsip send register ${provider.key}-registration`, ] const results = [] @@ -369,14 +402,17 @@ const runAsteriskReload = async () => { } } -const readAsteriskTrunkStatus = async () => { +const readAsteriskTrunkStatus = async (trunk?: any) => { + const provider = trunkProviderConfig(trunk?.provider) + const registrationName = `${provider.key}-registration` const registrations = await runAsteriskAmiCommand("pjsip show registrations") const raw = registrations.raw || "" return { reachable: registrations.ok, - registered: /telekom-registration[\s\S]*Registered/i.test(raw), - hasRegistration: raw.includes("telekom-registration"), + provider: provider.key, + registered: new RegExp(`${registrationName}[\\s\\S]*Registered`, "i").test(raw), + hasRegistration: raw.includes(registrationName), registrations: raw, } } @@ -397,19 +433,34 @@ export default async function telephonyRoutes(server: FastifyInstance) { return trunk || null } + const loadActiveTenantTrunk = async (tenantId: number | null) => { + if (!tenantId) return null + + const trunks = await server.db + .select() + .from(telephonyTrunks) + .where(eq(telephonyTrunks.tenantId, tenantId)) + + return trunks.find((trunk) => trunk.enabled) + || trunks.find((trunk) => trunk.provider === "easybell") + || trunks.find((trunk) => trunk.provider === "telekom") + || trunks[0] + || null + } + const externalTelephonyConfig = async (tenantId: number | null) => { - const trunk = await loadTenantTrunk(tenantId) + const trunk = await loadActiveTenantTrunk(tenantId) if (trunk) { return { enabled: trunk.enabled, provider: trunk.provider, - inboundExtension: trunk.inboundExtension, - outboundPrefix: trunk.outboundPrefix, - registrar: trunk.registrar, - externalSignalingAddress: trunk.externalSignalingAddress, - externalMediaAddress: trunk.externalMediaAddress, - localNetworks: trunk.localNetworks, - sipUserConfigured: Boolean(trunk.sipUser), + inboundExtension: trunk.inboundExtension, + outboundPrefix: trunk.outboundPrefix, + registrar: trunk.registrar, + externalSignalingAddress: trunk.externalSignalingAddress, + externalMediaAddress: trunk.externalMediaAddress, + localNetworks: trunk.localNetworks, + sipUserConfigured: Boolean(trunk.sipUser), authUserConfigured: Boolean(trunk.authUser), passwordConfigured: Boolean(trunk.password), callerIdConfigured: Boolean(trunk.callerId), @@ -483,23 +534,26 @@ export default async function telephonyRoutes(server: FastifyInstance) { server.get("/telephony/trunk-config", async (req) => { const tenantId = requireTenant(req.user.tenant_id) - const trunk = await loadTenantTrunk(tenantId) + const provider = normalizeTrunkProvider((req.query as { provider?: string })?.provider) + const trunk = await loadTenantTrunk(tenantId, provider) - return sanitizeTrunk(trunk) + return sanitizeTrunk(trunk || { provider }) }) server.put("/telephony/trunk-config", async (req, reply) => { const tenantId = requireTenant(req.user.tenant_id) const body = (req.body || {}) as any - const existing = await loadTenantTrunk(tenantId) + const provider = normalizeTrunkProvider(bodyString(body, "provider")) + const providerConfig = trunkProviderConfig(provider) + const existing = await loadTenantTrunk(tenantId, provider) const password = bodyString(body, "password") const clearPassword = body?.clearPassword === true const now = new Date() const values = { tenantId, - provider: "telekom", + provider, enabled: Boolean(body.enabled), - registrar: bodyString(body, "registrar") || "tel.t-online.de", + registrar: bodyString(body, "registrar") || providerConfig.defaultRegistrar, sipUser: bodyString(body, "sipUser"), authUser: bodyString(body, "authUser"), callerId: bodyString(body, "callerId"), @@ -525,10 +579,21 @@ export default async function telephonyRoutes(server: FastifyInstance) { .set(values) .where(and( eq(telephonyTrunks.tenantId, tenantId), - eq(telephonyTrunks.provider, "telekom") + eq(telephonyTrunks.provider, provider) )) .returning() + if (values.enabled) { + await server.db + .update(telephonyTrunks) + .set({ enabled: false, updatedAt: now, updatedBy: req.user.user_id }) + .where(and( + eq(telephonyTrunks.tenantId, tenantId), + eq(telephonyTrunks.enabled, true), + eq(telephonyTrunks.provider, provider === "easybell" ? "telekom" : "easybell") + )) + } + return sanitizeTrunk(updated) } @@ -541,12 +606,23 @@ export default async function telephonyRoutes(server: FastifyInstance) { }) .returning() + if (values.enabled) { + await server.db + .update(telephonyTrunks) + .set({ enabled: false, updatedAt: now, updatedBy: req.user.user_id }) + .where(and( + eq(telephonyTrunks.tenantId, tenantId), + eq(telephonyTrunks.enabled, true), + eq(telephonyTrunks.provider, provider === "easybell" ? "telekom" : "easybell") + )) + } + return sanitizeTrunk(created) }) server.post("/telephony/trunk-config/apply", async (req, reply) => { const tenantId = requireTenant(req.user.tenant_id) - const trunk = await loadTenantTrunk(tenantId) + const trunk = await loadActiveTenantTrunk(tenantId) if (!trunk) { return reply.code(404).send({ error: "Telefonie-Trunk ist noch nicht konfiguriert." }) @@ -564,8 +640,8 @@ export default async function telephonyRoutes(server: FastifyInstance) { let warning: string | null = null try { - reload = await runAsteriskReload() - status = await readAsteriskTrunkStatus() + reload = await runAsteriskReload(trunk) + status = await readAsteriskTrunkStatus(trunk) } catch (error: any) { warning = error?.message || "Asterisk konnte nicht neu geladen werden." } @@ -581,10 +657,11 @@ export default async function telephonyRoutes(server: FastifyInstance) { }) server.get("/telephony/trunk-status", async (req, reply) => { - requireTenant(req.user.tenant_id) + const tenantId = requireTenant(req.user.tenant_id) + const trunk = await loadActiveTenantTrunk(tenantId) try { - return await readAsteriskTrunkStatus() + return await readAsteriskTrunkStatus(trunk) } catch (error: any) { return reply.code(200).send({ reachable: false, diff --git a/frontend/pages/communication/phone-setup.vue b/frontend/pages/communication/phone-setup.vue index 758e08d..f7c2257 100644 --- a/frontend/pages/communication/phone-setup.vue +++ b/frontend/pages/communication/phone-setup.vue @@ -52,7 +52,7 @@ const applyTrunk = async () => { trunkStatus.value = res?.status || trunkStatus.value toast.add({ title: res?.warning ? "Trunk-Konfiguration geschrieben" : "Trunk angewendet", - description: res?.warning || (res?.status?.registered ? "Telekom-Registration ist aktiv." : "Asterisk wurde neu geladen."), + description: res?.warning || (res?.status?.registered ? "Trunk-Registration ist aktiv." : "Asterisk wurde neu geladen."), color: res?.warning ? "orange" : "success" }) } catch (error) { @@ -115,7 +115,7 @@ onMounted(async () => { Externe Telefonie

- Telekom-Anbindung über den lokalen Asterisk-Trunk. + SIP-Trunk-Anbindung über den lokalen Asterisk.

@@ -197,7 +197,7 @@ onMounted(async () => {
diff --git a/frontend/pages/settings/tenant.vue b/frontend/pages/settings/tenant.vue index eb68351..2168eb7 100644 --- a/frontend/pages/settings/tenant.vue +++ b/frontend/pages/settings/tenant.vue @@ -135,9 +135,26 @@ const mcpTokenForm = reactive({ const telephonyTrunkLoading = ref(false) const telephonyTrunkSaving = ref(false) const telephonyTrunkApplying = ref(false) +const telephonyProviderOptions = [ + { label: "Easybell", value: "easybell" }, + { label: "Telekom", value: "telekom" } +] +const telephonyProviderDefaults = { + easybell: { + registrar: "voip.easybell.de", + title: "Easybell SIP-Trunk", + description: "Nutze SIP-Benutzername und SIP-Passwort aus dem Easybell-Kundenportal. Der Registrar ist für SIP-Trunks in der Regel voip.easybell.de." + }, + telekom: { + registrar: "tel.t-online.de", + title: "Telekom Zugangsdaten", + description: "Die SIP-ID ist meistens deine Rufnummer mit Vorwahl ohne Leerzeichen und Sonderzeichen. Falls dein Anschluss die klassischen Zugangsdaten nutzt, kannst du den Auth-User aus Anschlusskennung, Zugangsnummer, #, Mitbenutzernummer und @t-online.de bilden." + } +} const telephonyTrunkForm = reactive({ + provider: "easybell", enabled: false, - registrar: "tel.t-online.de", + registrar: "voip.easybell.de", sipUser: "", authUser: "", password: "", @@ -150,6 +167,7 @@ const telephonyTrunkForm = reactive({ externalMediaAddress: "", localNetworks: "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8" }) +const activeTelephonyProvider = computed(() => telephonyProviderDefaults[telephonyTrunkForm.provider] || telephonyProviderDefaults.easybell) const setupPage = async () => { itemInfo.value = auth.activeTenantData @@ -215,9 +233,10 @@ const loadTelephonyTrunk = async () => { telephonyTrunkLoading.value = true try { - const res = await useNuxtApp().$api("/api/telephony/trunk-config") + const res = await useNuxtApp().$api(`/api/telephony/trunk-config?provider=${telephonyTrunkForm.provider}`) + telephonyTrunkForm.provider = res?.provider || telephonyTrunkForm.provider || "easybell" telephonyTrunkForm.enabled = Boolean(res?.enabled) - telephonyTrunkForm.registrar = res?.registrar || "tel.t-online.de" + telephonyTrunkForm.registrar = res?.registrar || activeTelephonyProvider.value.registrar telephonyTrunkForm.sipUser = res?.sipUser || "" telephonyTrunkForm.authUser = res?.authUser || "" telephonyTrunkForm.password = "" @@ -239,7 +258,7 @@ const loadTelephonyTrunk = async () => { const saveTelephonyTrunk = async () => { if (telephonyTrunkForm.enabled && (!telephonyTrunkForm.sipUser?.trim() || (!telephonyTrunkForm.password?.trim() && !telephonyTrunkForm.passwordConfigured))) { toast.add({ - title: "Telekom-Zugang unvollständig", + title: "Trunk-Zugang unvollständig", description: "Bitte gib mindestens SIP-ID und Kennwort an.", color: "orange" }) @@ -252,6 +271,7 @@ const saveTelephonyTrunk = async () => { const res = await useNuxtApp().$api("/api/telephony/trunk-config", { method: "PUT", body: { + provider: telephonyTrunkForm.provider, enabled: telephonyTrunkForm.enabled, registrar: telephonyTrunkForm.registrar, sipUser: telephonyTrunkForm.sipUser, @@ -292,7 +312,7 @@ const applyTelephonyTrunk = async () => { toast.add({ title: res?.warning ? "Trunk-Konfiguration geschrieben" : "Telefonie-Trunk angewendet", - description: res?.warning || (res?.status?.registered ? "Telekom-Registration ist aktiv." : "Asterisk wurde neu geladen."), + description: res?.warning || (res?.status?.registered ? "Trunk-Registration ist aktiv." : "Asterisk wurde neu geladen."), color: res?.warning ? "orange" : "success" }) } catch (error) { @@ -363,6 +383,14 @@ onMounted(() => { loadMcpTokens() loadTelephonyTrunk() }) + +watch(() => telephonyTrunkForm.provider, async (provider) => { + const defaults = telephonyProviderDefaults[provider] || telephonyProviderDefaults.easybell + if (!telephonyTrunkForm.registrar || ["tel.t-online.de", "voip.easybell.de"].includes(telephonyTrunkForm.registrar)) { + telephonyTrunkForm.registrar = defaults.registrar + } + await loadTelephonyTrunk() +})