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 }) }