Files
FEDEO/backend/src/routes/telephony.ts

688 lines
24 KiB
TypeScript

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"
const envFlag = (value: string | undefined, fallback: boolean) => {
if (value === undefined || value === "") return fallback
return ["1", "true", "yes", "on"].includes(value.toLowerCase())
}
const telephonyEnabled = () =>
envFlag(process.env.TELEPHONY_ENABLED, process.env.NODE_ENV !== "production")
const asteriskHttpStatusUrl = () =>
process.env.TELEPHONY_ASTERISK_HTTP_URL || "http://asterisk-dev:8088/ws"
const asteriskHttpStatusUrls = () => {
const configuredUrl = asteriskHttpStatusUrl()
return Array.from(new Set([
configuredUrl,
"http://asterisk-dev:8088/ws",
"http://host.docker.internal:8088/ws",
`http://127.0.0.1:${process.env.TELEPHONY_DEV_WS_PORT || "8088"}/ws`,
`http://localhost:${process.env.TELEPHONY_DEV_WS_PORT || "8088"}/ws`,
]))
}
const publicAsteriskWsUrl = () =>
process.env.TELEPHONY_ASTERISK_WS_URL || `ws://localhost:${process.env.TELEPHONY_DEV_WS_PORT || "8088"}/ws`
const defaultAsteriskGeneratedDir = () => {
const cwd = process.cwd()
return path.resolve(cwd, cwd.endsWith(`${path.sep}backend`) ? "../telephony/generated" : "telephony/generated")
}
const asteriskGeneratedDir = () =>
process.env.TELEPHONY_ASTERISK_GENERATED_DIR || defaultAsteriskGeneratedDir()
const defaultAsteriskAmiHost = () =>
process.env.NODE_ENV === "production" ? "asterisk-dev" : "127.0.0.1"
const asteriskAmiConfig = () => ({
host: process.env.TELEPHONY_ASTERISK_AMI_HOST || defaultAsteriskAmiHost(),
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"
const testAccounts = () => [
{
extension: process.env.TELEPHONY_TEST_EXTENSION || "1001",
password: process.env.TELEPHONY_TEST_PASSWORD || "fedeo-test-1001",
displayName: "FEDEO Test 1001",
},
{
extension: process.env.TELEPHONY_TEST_EXTENSION_2 || "1002",
password: process.env.TELEPHONY_TEST_PASSWORD_2 || "fedeo-test-1002",
displayName: "FEDEO Test 1002",
},
]
const sanitizeTrunk = (trunk: any) => ({
id: trunk?.id || null,
provider: trunk?.provider || "telekom",
enabled: Boolean(trunk?.enabled),
registrar: trunk?.registrar || "tel.t-online.de",
sipUser: trunk?.sipUser || "",
authUser: trunk?.authUser || "",
passwordConfigured: Boolean(trunk?.password),
callerId: trunk?.callerId || "",
inboundExtension: trunk?.inboundExtension || "1001",
outboundPrefix: trunk?.outboundPrefix || "0",
externalSignalingAddress: trunk?.externalSignalingAddress || "",
externalMediaAddress: trunk?.externalMediaAddress || "",
localNetworks: trunk?.localNetworks || "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8",
})
const envExternalTelephonyConfig = () => {
const provider = process.env.TELEPHONY_EXTERNAL_PROVIDER || (
envFlag(process.env.TELEPHONY_TELEKOM_ENABLED, false) ? "telekom" : ""
)
const enabled = envFlag(
process.env.TELEPHONY_EXTERNAL_ENABLED,
envFlag(process.env.TELEPHONY_TELEKOM_ENABLED, false)
)
return {
enabled,
provider: provider || null,
inboundExtension: process.env.TELEPHONY_EXTERNAL_INBOUND_EXTENSION
|| process.env.TELEPHONY_TELEKOM_INBOUND_EXTENSION
|| "1001",
outboundPrefix: process.env.TELEPHONY_TELEKOM_OUTBOUND_PREFIX || "0",
registrar: provider === "telekom" || envFlag(process.env.TELEPHONY_TELEKOM_ENABLED, false)
? (process.env.TELEPHONY_TELEKOM_REGISTRAR || "tel.t-online.de")
: null,
sipUserConfigured: Boolean(process.env.TELEPHONY_TELEKOM_SIP_USER),
authUserConfigured: Boolean(process.env.TELEPHONY_TELEKOM_AUTH_USER),
passwordConfigured: Boolean(process.env.TELEPHONY_TELEKOM_PASSWORD),
callerIdConfigured: Boolean(process.env.TELEPHONY_TELEKOM_CALLER_ID),
}
}
const fetchWithTimeout = async (url: string, timeoutMs = 2500) => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
return await fetch(url, { signal: controller.signal })
} finally {
clearTimeout(timeout)
}
}
const requireTenant = (tenantId: number | null) => {
if (!tenantId) {
throw Object.assign(new Error("Kein aktiver Mandant"), { statusCode: 400 })
}
return tenantId
}
const bodyString = (body: any, key: string) => {
const value = body?.[key]
return typeof value === "string" && value.trim() ? value.trim() : null
}
const bodyDate = (body: any, key: string) => {
const value = body?.[key]
if (!value) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
const durationSeconds = (startedAt?: Date | null, endedAt?: Date | null) => {
if (!startedAt || !endedAt) return 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
const externalMediaAddress = asteriskValue(trunk.externalMediaAddress || trunk.externalSignalingAddress)
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}>`,
...(externalMediaAddress ? [`media_address=${externalMediaAddress}`] : []),
"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 renderTelekomTransportConfig = (trunk: any) => {
const externalSignalingAddress = asteriskValue(trunk?.externalSignalingAddress)
const externalMediaAddress = asteriskValue(trunk?.externalMediaAddress || trunk?.externalSignalingAddress)
const localNetworks = asteriskValue(trunk?.localNetworks || "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8")
.split(/[\s,;]+/)
.map((entry) => entry.trim())
.filter(Boolean)
if (!externalSignalingAddress && !externalMediaAddress) {
return [
"; Von FEDEO generiert.",
"; Kein externes Asterisk-NAT-Rewrite konfiguriert.",
"",
].join("\n")
}
return [
"; Von FEDEO generiert. Änderungen am Transport benötigen einen Asterisk-Neustart.",
"[transport-udp](+)",
...(externalSignalingAddress ? [
`external_signaling_address=${externalSignalingAddress}`,
"external_signaling_port=5060",
] : []),
...(externalMediaAddress ? [`external_media_address=${externalMediaAddress}`] : []),
...localNetworks.map((network) => `local_net=${network}`),
"",
].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),
},
{
name: "pjsip.transport.conf",
content: renderTelekomTransportConfig(trunk),
},
]
await Promise.all(files.map(async (file) => {
const target = path.join(targetDir, file.name)
await fs.writeFile(target, `${file.content}\n`, { mode: 0o644 })
await fs.chmod(target, 0o644)
}))
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 = [
"module reload res_pjsip.so",
"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
const [trunk] = await server.db
.select()
.from(telephonyTrunks)
.where(and(
eq(telephonyTrunks.tenantId, tenantId),
eq(telephonyTrunks.provider, provider)
))
.limit(1)
return trunk || null
}
const externalTelephonyConfig = async (tenantId: number | null) => {
const trunk = await loadTenantTrunk(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),
authUserConfigured: Boolean(trunk.authUser),
passwordConfigured: Boolean(trunk.password),
callerIdConfigured: Boolean(trunk.callerId),
}
}
return envExternalTelephonyConfig()
}
server.get("/telephony/config", async (req) => ({
enabled: telephonyEnabled(),
provider: "asterisk",
mode: "local-test",
sipDomain: sipDomain(),
sipWebSocketUrl: publicAsteriskWsUrl(),
echoExtension: process.env.TELEPHONY_ECHO_EXTENSION || "600",
testAccounts: testAccounts(),
external: await externalTelephonyConfig(req.user?.tenant_id || null),
}))
server.get("/telephony/status", async () => {
const enabled = telephonyEnabled()
const urls = asteriskHttpStatusUrls()
let lastError: any = null
const attempts = []
for (const url of urls) {
try {
const response = await fetchWithTimeout(url)
attempts.push({
url,
reachable: true,
statusCode: response.status,
})
return {
enabled,
provider: "asterisk",
reachable: true,
statusCode: response.status,
statusUrl: url,
attempts,
message: enabled
? (response.ok ? "Asterisk ist erreichbar." : `Asterisk-HTTP ist erreichbar (HTTP ${response.status}).`)
: "Asterisk ist erreichbar, Telefonie ist aber noch nicht aktiviert.",
}
} catch (error: any) {
lastError = error
attempts.push({
url,
reachable: false,
message: error?.name === "AbortError"
? "Abgelaufen"
: (error?.message || "Nicht erreichbar"),
})
}
}
return {
enabled,
provider: "asterisk",
reachable: false,
statusUrl: urls[0],
attempts,
message: lastError?.name === "AbortError"
? "Asterisk-Statusabfrage ist abgelaufen."
: (lastError?.message || "Asterisk ist nicht erreichbar."),
}
})
server.get("/telephony/trunk-config", async (req) => {
const tenantId = requireTenant(req.user.tenant_id)
const trunk = await loadTenantTrunk(tenantId)
return sanitizeTrunk(trunk)
})
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 password = bodyString(body, "password")
const clearPassword = body?.clearPassword === true
const now = new Date()
const values = {
tenantId,
provider: "telekom",
enabled: Boolean(body.enabled),
registrar: bodyString(body, "registrar") || "tel.t-online.de",
sipUser: bodyString(body, "sipUser"),
authUser: bodyString(body, "authUser"),
callerId: bodyString(body, "callerId"),
inboundExtension: bodyString(body, "inboundExtension") || "1001",
outboundPrefix: bodyString(body, "outboundPrefix") || "0",
externalSignalingAddress: bodyString(body, "externalSignalingAddress"),
externalMediaAddress: bodyString(body, "externalMediaAddress"),
localNetworks: bodyString(body, "localNetworks") || "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8",
password: clearPassword ? null : (password || existing?.password || null),
updatedAt: now,
updatedBy: req.user.user_id,
}
if (values.enabled && (!values.sipUser || !values.password)) {
return reply.code(400).send({
error: "SIP-ID und Kennwort sind erforderlich, wenn der Trunk aktiviert wird.",
})
}
if (existing) {
const [updated] = await server.db
.update(telephonyTrunks)
.set(values)
.where(and(
eq(telephonyTrunks.tenantId, tenantId),
eq(telephonyTrunks.provider, "telekom")
))
.returning()
return sanitizeTrunk(updated)
}
const [created] = await server.db
.insert(telephonyTrunks)
.values({
...values,
createdAt: now,
createdBy: req.user.user_id,
})
.returning()
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(
Math.max(Number((req.query as { limit?: string })?.limit || 25), 1),
100
)
return await server.db
.select()
.from(telephonyCalls)
.where(eq(telephonyCalls.tenantId, tenantId))
.orderBy(desc(telephonyCalls.startedAt))
.limit(limit)
})
server.post("/telephony/calls", async (req, reply) => {
const tenantId = requireTenant(req.user.tenant_id)
const body = (req.body || {}) as any
const now = new Date()
const startedAt = bodyDate(body, "startedAt") || now
const direction = bodyString(body, "direction") === "incoming" ? "incoming" : "outgoing"
const status = bodyString(body, "status") || (direction === "incoming" ? "ringing" : "dialing")
const [created] = await server.db
.insert(telephonyCalls)
.values({
tenantId,
direction,
status,
localExtension: bodyString(body, "localExtension"),
remoteNumber: bodyString(body, "remoteNumber"),
remoteDisplayName: bodyString(body, "remoteDisplayName"),
sipCallId: bodyString(body, "sipCallId"),
startedAt,
createdBy: req.user.user_id,
})
.returning()
return reply.code(201).send(created)
})
server.patch("/telephony/calls/:id", async (req, reply) => {
const tenantId = requireTenant(req.user.tenant_id)
const params = req.params as { id: string }
const body = (req.body || {}) as any
const [existing] = await server.db
.select()
.from(telephonyCalls)
.where(and(
eq(telephonyCalls.tenantId, tenantId),
eq(telephonyCalls.id, params.id)
))
.limit(1)
if (!existing) {
return reply.code(404).send({ error: "Anruf nicht gefunden" })
}
const answeredAt = bodyDate(body, "answeredAt")
const endedAt = bodyDate(body, "endedAt")
const startedAt = existing.startedAt ? new Date(existing.startedAt) : null
const computedDuration = endedAt
? durationSeconds(answeredAt || startedAt, endedAt)
: null
const [updated] = await server.db
.update(telephonyCalls)
.set({
status: bodyString(body, "status") || existing.status,
localExtension: bodyString(body, "localExtension") || existing.localExtension,
remoteNumber: bodyString(body, "remoteNumber") || existing.remoteNumber,
remoteDisplayName: bodyString(body, "remoteDisplayName") || existing.remoteDisplayName,
sipCallId: bodyString(body, "sipCallId") || existing.sipCallId,
answeredAt: answeredAt || existing.answeredAt,
endedAt: endedAt || existing.endedAt,
durationSeconds: computedDuration ?? existing.durationSeconds,
updatedAt: new Date(),
updatedBy: req.user.user_id,
})
.where(and(
eq(telephonyCalls.tenantId, tenantId),
eq(telephonyCalls.id, params.id)
))
.returning()
return updated
})
}