KI-AGENT: Asterisk-Trunk aus FEDEO anwenden

This commit is contained in:
2026-05-21 16:40:19 +02:00
parent f6fb607008
commit b667a856d4
7 changed files with 406 additions and 3 deletions

View File

@@ -62,6 +62,11 @@ TELEPHONY_ENABLED=false
ASTERISK_IMAGE=andrius/asterisk:20 ASTERISK_IMAGE=andrius/asterisk:20
TELEPHONY_ASTERISK_HTTP_URL=http://asterisk-dev:8088/ws TELEPHONY_ASTERISK_HTTP_URL=http://asterisk-dev:8088/ws
TELEPHONY_ASTERISK_WS_URL=ws://localhost:8088/ws TELEPHONY_ASTERISK_WS_URL=ws://localhost:8088/ws
TELEPHONY_ASTERISK_GENERATED_DIR=/var/lib/fedeo/asterisk/generated
TELEPHONY_ASTERISK_AMI_HOST=asterisk-dev
TELEPHONY_ASTERISK_AMI_PORT=5038
TELEPHONY_ASTERISK_AMI_USER=fedeo
TELEPHONY_ASTERISK_AMI_PASSWORD=fedeo-ami-dev
TELEPHONY_SIP_DOMAIN=localhost TELEPHONY_SIP_DOMAIN=localhost
TELEPHONY_TEST_EXTENSION=1001 TELEPHONY_TEST_EXTENSION=1001
TELEPHONY_TEST_PASSWORD=fedeo-test-1001 TELEPHONY_TEST_PASSWORD=fedeo-test-1001

View File

@@ -1,4 +1,7 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { promises as fs } from "node:fs"
import net from "node:net"
import path from "node:path"
import { and, desc, eq } from "drizzle-orm" import { and, desc, eq } from "drizzle-orm"
import { telephonyCalls, telephonyTrunks } from "../../db/schema" import { telephonyCalls, telephonyTrunks } from "../../db/schema"
@@ -27,6 +30,16 @@ const asteriskHttpStatusUrls = () => {
const publicAsteriskWsUrl = () => const publicAsteriskWsUrl = () =>
process.env.TELEPHONY_ASTERISK_WS_URL || `ws://localhost:${process.env.TELEPHONY_DEV_WS_PORT || "8088"}/ws` process.env.TELEPHONY_ASTERISK_WS_URL || `ws://localhost:${process.env.TELEPHONY_DEV_WS_PORT || "8088"}/ws`
const asteriskGeneratedDir = () =>
process.env.TELEPHONY_ASTERISK_GENERATED_DIR || "/var/lib/fedeo/asterisk/generated"
const asteriskAmiConfig = () => ({
host: process.env.TELEPHONY_ASTERISK_AMI_HOST || "asterisk-dev",
port: Number(process.env.TELEPHONY_ASTERISK_AMI_PORT || 5038),
username: process.env.TELEPHONY_ASTERISK_AMI_USER || "fedeo",
password: process.env.TELEPHONY_ASTERISK_AMI_PASSWORD || "fedeo-ami-dev",
})
const sipDomain = () => const sipDomain = () =>
process.env.TELEPHONY_SIP_DOMAIN || "localhost" process.env.TELEPHONY_SIP_DOMAIN || "localhost"
@@ -119,6 +132,208 @@ const durationSeconds = (startedAt?: Date | null, endedAt?: Date | null) => {
return Math.max(0, Math.round((endedAt.getTime() - startedAt.getTime()) / 1000)) return Math.max(0, Math.round((endedAt.getTime() - startedAt.getTime()) / 1000))
} }
const asteriskValue = (value: string | null | undefined) =>
String(value || "").replace(/[\r\n]/g, "").trim()
const renderTelekomPjsipConfig = (trunk: any) => {
if (!trunk?.enabled) {
return [
"; Von FEDEO generiert.",
"; Telekom-Trunk ist deaktiviert.",
"",
].join("\n")
}
const registrar = asteriskValue(trunk.registrar) || "tel.t-online.de"
const sipUser = asteriskValue(trunk.sipUser)
const authUser = asteriskValue(trunk.authUser) || sipUser
const password = asteriskValue(trunk.password)
const callerId = asteriskValue(trunk.callerId) || sipUser
return [
"; Von FEDEO generiert. Änderungen im Container können überschrieben werden.",
"[telekom-auth]",
"type=auth",
"auth_type=userpass",
`username=${authUser}`,
`password=${password}`,
"",
"[telekom-aor]",
"type=aor",
`contact=sip:${registrar}`,
"",
"[telekom]",
"type=endpoint",
"transport=transport-udp",
"context=from-telekom",
"disallow=all",
"allow=alaw,ulaw",
"aors=telekom-aor",
"outbound_auth=telekom-auth",
`from_user=${sipUser}`,
`from_domain=${registrar}`,
`callerid=Telekom <${callerId}>`,
"direct_media=no",
"force_rport=yes",
"rewrite_contact=yes",
"rtp_symmetric=yes",
"timers=no",
"",
"[telekom-identify]",
"type=identify",
"endpoint=telekom",
`match=${registrar}`,
"",
"[telekom-registration]",
"type=registration",
"transport=transport-udp",
"outbound_auth=telekom-auth",
`server_uri=sip:${registrar}`,
`client_uri=sip:${sipUser}@${registrar}`,
`contact_user=${sipUser}`,
"retry_interval=60",
"forbidden_retry_interval=300",
"expiration=480",
"line=yes",
"endpoint=telekom",
"",
].join("\n")
}
const renderTelekomExtensionsConfig = (trunk: any) => {
if (!trunk?.enabled) {
return [
"; Von FEDEO generiert.",
"; Telekom-Routing ist deaktiviert.",
"",
].join("\n")
}
const inboundExtension = asteriskValue(trunk.inboundExtension) || "1001"
const outboundPrefix = asteriskValue(trunk.outboundPrefix) || "0"
const escapedPrefix = outboundPrefix.replace(/[^0-9*#+]/g, "")
const callerId = asteriskValue(trunk.callerId) || asteriskValue(trunk.sipUser)
return [
"; 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})",
` same => n,Set(CALLERID(num)=${callerId})`,
" same => n,Dial(PJSIP/${EXTEN}@telekom,60)",
" same => n,Hangup()",
"",
"exten => _+X.,1,NoOp(FEDEO ausgehend über Telekom: ${EXTEN})",
` same => n,Set(CALLERID(num)=${callerId})`,
" same => n,Dial(PJSIP/${EXTEN}@telekom,60)",
" same => n,Hangup()",
"",
"[from-telekom]",
"exten => s,1,NoOp(FEDEO eingehend über Telekom)",
` same => n,Dial(PJSIP/${inboundExtension},30)`,
" same => n,Hangup()",
"",
"exten => _X!,1,NoOp(FEDEO eingehend über Telekom: ${EXTEN})",
` same => n,Dial(PJSIP/${inboundExtension},30)`,
" same => n,Hangup()",
"",
].join("\n")
}
const writeAsteriskTrunkConfig = async (trunk: any) => {
const targetDir = asteriskGeneratedDir()
await fs.mkdir(targetDir, { recursive: true })
const files = [
{
name: "pjsip.telekom.conf",
content: renderTelekomPjsipConfig(trunk),
},
{
name: "extensions.telekom.conf",
content: renderTelekomExtensionsConfig(trunk),
},
]
await Promise.all(files.map(async (file) => {
const target = path.join(targetDir, file.name)
await fs.writeFile(target, `${file.content}\n`, { mode: 0o600 })
}))
return files.map((file) => path.join(targetDir, file.name))
}
const runAsteriskAmiCommand = async (command: string, timeoutMs = 5000) => {
const config = asteriskAmiConfig()
return await new Promise<{ command: string, ok: boolean, raw: string }>((resolve, reject) => {
const socket = net.createConnection(config.port, config.host)
let raw = ""
let settled = false
const finish = (ok: boolean) => {
if (settled) return
settled = true
socket.destroy()
resolve({ command, ok, raw })
}
socket.setTimeout(timeoutMs)
socket.on("connect", () => {
socket.write([
"Action: Login",
`Username: ${config.username}`,
`Secret: ${config.password}`,
"Events: off",
"",
"Action: Command",
`Command: ${command}`,
"",
"Action: Logoff",
"",
].join("\r\n"))
})
socket.on("data", (chunk) => {
raw += chunk.toString("utf8")
if (raw.includes("Goodbye")) finish(!raw.includes("Authentication failed"))
})
socket.on("timeout", () => finish(raw.length > 0))
socket.on("end", () => finish(!raw.includes("Authentication failed")))
socket.on("error", reject)
})
}
const runAsteriskReload = async () => {
const commands = [
"pjsip reload",
"dialplan reload",
"pjsip send register telekom-registration",
]
const results = []
for (const command of commands) {
results.push(await runAsteriskAmiCommand(command))
}
return {
ok: results.every((result) => result.ok),
commands: results,
}
}
const readAsteriskTrunkStatus = async () => {
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"),
registrations: raw,
}
}
export default async function telephonyRoutes(server: FastifyInstance) { export default async function telephonyRoutes(server: FastifyInstance) {
const loadTenantTrunk = async (tenantId: number | null, provider = "telekom") => { const loadTenantTrunk = async (tenantId: number | null, provider = "telekom") => {
if (!tenantId) return null if (!tenantId) return null
@@ -276,6 +491,58 @@ export default async function telephonyRoutes(server: FastifyInstance) {
return sanitizeTrunk(created) return sanitizeTrunk(created)
}) })
server.post("/telephony/trunk-config/apply", async (req, reply) => {
const tenantId = requireTenant(req.user.tenant_id)
const trunk = await loadTenantTrunk(tenantId)
if (!trunk) {
return reply.code(404).send({ error: "Telefonie-Trunk ist noch nicht konfiguriert." })
}
if (trunk.enabled && (!trunk.sipUser || !trunk.password)) {
return reply.code(400).send({
error: "SIP-ID und Kennwort sind erforderlich, bevor der Trunk angewendet werden kann.",
})
}
const files = await writeAsteriskTrunkConfig(trunk)
let reload: any = null
let status: any = null
let warning: string | null = null
try {
reload = await runAsteriskReload()
status = await readAsteriskTrunkStatus()
} catch (error: any) {
warning = error?.message || "Asterisk konnte nicht neu geladen werden."
}
return {
generated: true,
files,
trunk: sanitizeTrunk(trunk),
reload,
status,
warning,
}
})
server.get("/telephony/trunk-status", async (req, reply) => {
requireTenant(req.user.tenant_id)
try {
return await readAsteriskTrunkStatus()
} catch (error: any) {
return reply.code(200).send({
reachable: false,
registered: false,
hasRegistration: false,
registrations: "",
message: error?.message || "Asterisk-AMI ist nicht erreichbar.",
})
}
})
server.get("/telephony/calls", async (req) => { server.get("/telephony/calls", async (req) => {
const tenantId = requireTenant(req.user.tenant_id) const tenantId = requireTenant(req.user.tenant_id)
const limit = Math.min( const limit = Math.min(

View File

@@ -69,6 +69,13 @@ services:
- TELEPHONY_EXTERNAL_PROVIDER=${TELEPHONY_EXTERNAL_PROVIDER:-} - TELEPHONY_EXTERNAL_PROVIDER=${TELEPHONY_EXTERNAL_PROVIDER:-}
- TELEPHONY_EXTERNAL_ENABLED=${TELEPHONY_EXTERNAL_ENABLED:-false} - TELEPHONY_EXTERNAL_ENABLED=${TELEPHONY_EXTERNAL_ENABLED:-false}
- TELEPHONY_EXTERNAL_INBOUND_EXTENSION=${TELEPHONY_EXTERNAL_INBOUND_EXTENSION:-1001} - TELEPHONY_EXTERNAL_INBOUND_EXTENSION=${TELEPHONY_EXTERNAL_INBOUND_EXTENSION:-1001}
- TELEPHONY_ASTERISK_GENERATED_DIR=${TELEPHONY_ASTERISK_GENERATED_DIR:-/var/lib/fedeo/asterisk/generated}
- TELEPHONY_ASTERISK_AMI_HOST=${TELEPHONY_ASTERISK_AMI_HOST:-asterisk-dev}
- TELEPHONY_ASTERISK_AMI_PORT=${TELEPHONY_ASTERISK_AMI_PORT:-5038}
- TELEPHONY_ASTERISK_AMI_USER=${TELEPHONY_ASTERISK_AMI_USER:-fedeo}
- TELEPHONY_ASTERISK_AMI_PASSWORD=${TELEPHONY_ASTERISK_AMI_PASSWORD:-fedeo-ami-dev}
volumes:
- asterisk-generated:/var/lib/fedeo/asterisk/generated
networks: networks:
- traefik - traefik
labels: labels:
@@ -124,7 +131,7 @@ services:
- -c - -c
- /usr/local/bin/render-asterisk-config.sh && asterisk -f - /usr/local/bin/render-asterisk-config.sh && asterisk -f
volumes: volumes:
- ./telephony/asterisk:/etc/asterisk:ro - ./telephony/asterisk:/etc/asterisk
- asterisk-generated:/etc/asterisk/generated - asterisk-generated:/etc/asterisk/generated
- ./telephony/render-asterisk-config.sh:/usr/local/bin/render-asterisk-config.sh:ro - ./telephony/render-asterisk-config.sh:/usr/local/bin/render-asterisk-config.sh:ro
ports: ports:

View File

@@ -25,6 +25,8 @@ TELEPHONY_TELEKOM_AUTH_USER=<anschlusskennung><zugangsnummer>#<mitbenutzernummer
Das Kennwort wird nicht wieder an das Frontend zurückgegeben. In der Oberfläche siehst du nur, ob ein Kennwort gespeichert ist. Das Kennwort wird nicht wieder an das Frontend zurückgegeben. In der Oberfläche siehst du nur, ob ein Kennwort gespeichert ist.
Nach dem Speichern muss die Konfiguration mit **In Asterisk anwenden** in die Asterisk-Include-Dateien geschrieben werden. FEDEO lädt danach PJSIP und den Dialplan über AMI neu und fordert die Telekom-Registration an. Den Laufzeitstatus findest du unter **Kommunikation -> Telefonie Setup** im Bereich **Externe Telefonie**.
## `.env` Fallback ## `.env` Fallback
Die `.env`-Werte bleiben nur als lokaler Fallback für den Asterisk-Teststack erhalten, falls keine mandantenbezogene Konfiguration gepflegt ist. Die `.env`-Werte bleiben nur als lokaler Fallback für den Asterisk-Teststack erhalten, falls keine mandantenbezogene Konfiguration gepflegt ist.
@@ -42,6 +44,11 @@ TELEPHONY_TELEKOM_PASSWORD=<persönliches_kennwort_oder_sip_kennwort>
TELEPHONY_TELEKOM_CALLER_ID=<rufnummer_mit_vorwahl_ohne_sonderzeichen> TELEPHONY_TELEKOM_CALLER_ID=<rufnummer_mit_vorwahl_ohne_sonderzeichen>
TELEPHONY_TELEKOM_INBOUND_EXTENSION=1001 TELEPHONY_TELEKOM_INBOUND_EXTENSION=1001
TELEPHONY_TELEKOM_OUTBOUND_PREFIX=0 TELEPHONY_TELEKOM_OUTBOUND_PREFIX=0
TELEPHONY_ASTERISK_GENERATED_DIR=/var/lib/fedeo/asterisk/generated
TELEPHONY_ASTERISK_AMI_HOST=asterisk-dev
TELEPHONY_ASTERISK_AMI_PORT=5038
TELEPHONY_ASTERISK_AMI_USER=fedeo
TELEPHONY_ASTERISK_AMI_PASSWORD=fedeo-ami-dev
``` ```
Wenn `TELEPHONY_TELEKOM_AUTH_USER` leer bleibt, verwendet Asterisk automatisch `TELEPHONY_TELEKOM_SIP_USER` als Authentifizierungsnutzer. Wenn `TELEPHONY_TELEKOM_AUTH_USER` leer bleibt, verwendet Asterisk automatisch `TELEPHONY_TELEKOM_SIP_USER` als Authentifizierungsnutzer.

View File

@@ -1,4 +1,5 @@
<script setup> <script setup>
const toast = useToast()
const { const {
loading, loading,
statusLoading, statusLoading,
@@ -20,7 +21,55 @@ const {
testWebSocket, testWebSocket,
} = useTelephonySoftphone() } = useTelephonySoftphone()
onMounted(loadTelephony) const trunkStatus = ref(null)
const trunkStatusLoading = ref(false)
const trunkApplying = ref(false)
const loadTrunkStatus = async () => {
trunkStatusLoading.value = true
try {
trunkStatus.value = await useNuxtApp().$api("/api/telephony/trunk-status")
} catch (error) {
trunkStatus.value = {
reachable: false,
registered: false,
hasRegistration: false,
message: error?.data?.error || error?.message || "Trunk-Status konnte nicht geladen werden."
}
} finally {
trunkStatusLoading.value = false
}
}
const applyTrunk = async () => {
trunkApplying.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/trunk-config/apply", {
method: "POST"
})
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."),
color: res?.warning ? "orange" : "success"
})
} catch (error) {
toast.add({
title: "Trunk konnte nicht angewendet werden",
description: error?.data?.error || error?.message,
color: "error"
})
} finally {
trunkApplying.value = false
}
}
onMounted(async () => {
await loadTelephony()
await loadTrunkStatus()
})
</script> </script>
<template> <template>
@@ -143,6 +192,33 @@ onMounted(loadTelephony)
:description="config?.external?.callerIdConfigured ? 'Konfiguriert' : 'Optional'" :description="config?.external?.callerIdConfigured ? 'Konfiguriert' : 'Optional'"
/> />
</div> </div>
<div class="mt-4 grid gap-3 lg:grid-cols-[1fr_auto] lg:items-center">
<UAlert
:color="trunkStatus?.registered ? 'success' : (trunkStatus?.reachable ? 'warning' : 'neutral')"
:icon="trunkStatus?.registered ? 'i-heroicons-check-circle' : (trunkStatus?.reachable ? 'i-heroicons-exclamation-triangle' : 'i-heroicons-signal-slash')"
:title="trunkStatus?.registered ? 'Telekom-Trunk registriert' : (trunkStatus?.hasRegistration ? 'Telekom-Trunk nicht registriert' : 'Keine Telekom-Registration aktiv')"
:description="trunkStatus?.message || (trunkStatus?.reachable ? 'Asterisk-AMI ist erreichbar.' : 'Asterisk-AMI ist noch nicht erreichbar.')"
/>
<div class="flex flex-wrap gap-2 lg:justify-end">
<UButton
icon="i-heroicons-signal"
variant="outline"
:loading="trunkStatusLoading"
@click="loadTrunkStatus"
>
Status prüfen
</UButton>
<UButton
icon="i-heroicons-arrow-path-rounded-square"
variant="soft"
:loading="trunkApplying"
@click="applyTrunk"
>
Trunk anwenden
</UButton>
</div>
</div>
</UCard> </UCard>
<div class="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]"> <div class="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">

View File

@@ -134,6 +134,7 @@ const mcpTokenForm = reactive({
}) })
const telephonyTrunkLoading = ref(false) const telephonyTrunkLoading = ref(false)
const telephonyTrunkSaving = ref(false) const telephonyTrunkSaving = ref(false)
const telephonyTrunkApplying = ref(false)
const telephonyTrunkForm = reactive({ const telephonyTrunkForm = reactive({
enabled: false, enabled: false,
registrar: "tel.t-online.de", registrar: "tel.t-online.de",
@@ -272,6 +273,30 @@ const saveTelephonyTrunk = async () => {
} }
} }
const applyTelephonyTrunk = async () => {
telephonyTrunkApplying.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/trunk-config/apply", {
method: "POST"
})
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."),
color: res?.warning ? "orange" : "success"
})
} catch (error) {
toast.add({
title: "Telefonie-Trunk konnte nicht angewendet werden",
description: error?.data?.error || error?.message,
color: "error"
})
} finally {
telephonyTrunkApplying.value = false
}
}
const createMcpToken = async () => { const createMcpToken = async () => {
if (!mcpTokenForm.name?.trim()) { if (!mcpTokenForm.name?.trim()) {
toast.add({ title: "Name fehlt", description: "Bitte gib einen Namen für den Token an.", color: "orange" }) toast.add({ title: "Name fehlt", description: "Bitte gib einen Namen für den Token an.", color: "orange" })
@@ -516,6 +541,15 @@ onMounted(() => {
> >
Telefonie-Trunk speichern Telefonie-Trunk speichern
</UButton> </UButton>
<UButton
icon="i-heroicons-arrow-path-rounded-square"
color="primary"
variant="soft"
:loading="telephonyTrunkApplying"
@click="applyTelephonyTrunk"
>
In Asterisk anwenden
</UButton>
</div> </div>
</div> </div>

View File

@@ -1,2 +1,9 @@
[general] [general]
enabled=no enabled=yes
port=5038
bindaddr=0.0.0.0
[fedeo]
secret=fedeo-ami-dev
read=system,call,log,verbose,command,agent,user,config,dtmf,reporting,cdr,dialplan
write=system,call,log,verbose,command,agent,user,config,originate,reporting