KI-AGENT: Asterisk-Trunk aus FEDEO anwenden
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
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 { telephonyCalls, telephonyTrunks } from "../../db/schema"
|
||||
|
||||
@@ -27,6 +30,16 @@ const asteriskHttpStatusUrls = () => {
|
||||
const publicAsteriskWsUrl = () =>
|
||||
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 = () =>
|
||||
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))
|
||||
}
|
||||
|
||||
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) {
|
||||
const loadTenantTrunk = async (tenantId: number | null, provider = "telekom") => {
|
||||
if (!tenantId) return null
|
||||
@@ -276,6 +491,58 @@ export default async function telephonyRoutes(server: FastifyInstance) {
|
||||
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) => {
|
||||
const tenantId = requireTenant(req.user.tenant_id)
|
||||
const limit = Math.min(
|
||||
|
||||
Reference in New Issue
Block a user