KI-AGENT: Nebenstellen und Standardroute für Telefonie ergänzen
This commit is contained in:
@@ -2,8 +2,19 @@ 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"
|
||||
import { randomBytes } from "node:crypto"
|
||||
import { and, desc, eq, inArray } from "drizzle-orm"
|
||||
import {
|
||||
authProfileBranches,
|
||||
authProfiles,
|
||||
authProfileTeams,
|
||||
authUsers,
|
||||
branches,
|
||||
teams,
|
||||
telephonyCalls,
|
||||
telephonyExtensions,
|
||||
telephonyTrunks,
|
||||
} from "../../db/schema"
|
||||
|
||||
const envFlag = (value: string | undefined, fallback: boolean) => {
|
||||
if (value === undefined || value === "") return fallback
|
||||
@@ -99,6 +110,7 @@ const sanitizeTrunk = (trunk: any) => ({
|
||||
passwordConfigured: Boolean(trunk?.password),
|
||||
callerId: trunk?.callerId || "",
|
||||
inboundExtension: trunk?.inboundExtension || "1001",
|
||||
defaultRouteExtensionId: trunk?.defaultRouteExtensionId || null,
|
||||
outboundPrefix: trunk?.outboundPrefix || "0",
|
||||
externalSignalingAddress: trunk?.externalSignalingAddress || "",
|
||||
externalMediaAddress: trunk?.externalMediaAddress || "",
|
||||
@@ -155,6 +167,26 @@ const bodyString = (body: any, key: string) => {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
const bodyNumber = (body: any, key: string) => {
|
||||
const value = body?.[key]
|
||||
if (value === null || value === undefined || value === "") return null
|
||||
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : null
|
||||
}
|
||||
|
||||
const normalizeExtensionTargetType = (value: string | null | undefined) => {
|
||||
if (value === "team") return "team"
|
||||
if (value === "branch") return "branch"
|
||||
return "user"
|
||||
}
|
||||
|
||||
const normalizeExtension = (value: string | null | undefined) =>
|
||||
asteriskValue(value).replace(/[^0-9*#+]/g, "")
|
||||
|
||||
const generateSipPassword = () =>
|
||||
randomBytes(18).toString("base64url")
|
||||
|
||||
const bodyDate = (body: any, key: string) => {
|
||||
const value = body?.[key]
|
||||
if (!value) return null
|
||||
@@ -319,6 +351,82 @@ const renderWebRtcConfig = (trunk: any) => {
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const extensionDialTarget = (extension: any, dialTargets: string[]) => {
|
||||
const dial = dialTargets.length
|
||||
? `Dial(${dialTargets.map((target) => `PJSIP/${target}`).join("&")},30)`
|
||||
: "Hangup()"
|
||||
|
||||
return [
|
||||
`exten => ${extension.extension},1,NoOp(FEDEO Nebenstelle ${extension.extension}: ${extension.displayName || extension.targetType})`,
|
||||
` same => n,${dial}`,
|
||||
" same => n,Hangup()",
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const renderTelephonyExtensionsPjsipConfig = (extensions: any[]) => {
|
||||
const userExtensions = extensions.filter((extension) =>
|
||||
extension.enabled && extension.targetType === "user"
|
||||
)
|
||||
|
||||
if (!userExtensions.length) {
|
||||
return [
|
||||
"; Von FEDEO generiert.",
|
||||
"; Keine Benutzer-Nebenstellen konfiguriert.",
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
return [
|
||||
"; Von FEDEO generiert. Änderungen im Container können überschrieben werden.",
|
||||
...userExtensions.flatMap((extension) => {
|
||||
const sipUsername = asteriskValue(extension.sipUsername) || extension.extension
|
||||
const sipPassword = asteriskValue(extension.sipPassword)
|
||||
const displayName = asteriskValue(extension.displayName) || `FEDEO ${extension.extension}`
|
||||
|
||||
return [
|
||||
`[${extension.extension}](fedeo-webrtc)`,
|
||||
`auth=${extension.extension}-auth`,
|
||||
`aors=${extension.extension}`,
|
||||
`callerid=${displayName} <${extension.extension}>`,
|
||||
"",
|
||||
`[${extension.extension}-auth]`,
|
||||
"type=auth",
|
||||
"auth_type=userpass",
|
||||
`username=${sipUsername}`,
|
||||
`password=${sipPassword}`,
|
||||
"",
|
||||
`[${extension.extension}]`,
|
||||
"type=aor",
|
||||
"max_contacts=5",
|
||||
"remove_existing=yes",
|
||||
"support_path=yes",
|
||||
"",
|
||||
]
|
||||
}),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const renderTelephonyExtensionsDialplanConfig = (extensions: any[], dialTargetsById: Map<string, string[]>) => {
|
||||
const enabledExtensions = extensions.filter((extension) => extension.enabled)
|
||||
|
||||
if (!enabledExtensions.length) {
|
||||
return [
|
||||
"; Von FEDEO generiert.",
|
||||
"; Keine Nebenstellen konfiguriert.",
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
return [
|
||||
"; Von FEDEO generiert. Änderungen im Container können überschrieben werden.",
|
||||
"[fedeo-local]",
|
||||
...enabledExtensions.map((extension) =>
|
||||
extensionDialTarget(extension, dialTargetsById.get(extension.id) || [])
|
||||
),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const renderTelekomTransportConfig = (trunk: any) => {
|
||||
const externalSignalingAddress = asteriskValue(trunk?.externalSignalingAddress)
|
||||
const externalMediaAddress = asteriskValue(trunk?.externalMediaAddress || trunk?.externalSignalingAddress)
|
||||
@@ -348,7 +456,7 @@ const renderTelekomTransportConfig = (trunk: any) => {
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const writeAsteriskTrunkConfig = async (trunk: any) => {
|
||||
const writeAsteriskTrunkConfig = async (trunk: any, extensions: any[] = [], dialTargetsById = new Map<string, string[]>()) => {
|
||||
const targetDir = asteriskGeneratedDir()
|
||||
await fs.mkdir(targetDir, { recursive: true })
|
||||
|
||||
@@ -369,6 +477,14 @@ const writeAsteriskTrunkConfig = async (trunk: any) => {
|
||||
name: "pjsip.webrtc.conf",
|
||||
content: renderWebRtcConfig(trunk),
|
||||
},
|
||||
{
|
||||
name: "pjsip.extensions.conf",
|
||||
content: renderTelephonyExtensionsPjsipConfig(extensions),
|
||||
},
|
||||
{
|
||||
name: "extensions.fedeo.conf",
|
||||
content: renderTelephonyExtensionsDialplanConfig(extensions, dialTargetsById),
|
||||
},
|
||||
]
|
||||
|
||||
await Promise.all(files.map(async (file) => {
|
||||
@@ -485,6 +601,113 @@ export default async function telephonyRoutes(server: FastifyInstance) {
|
||||
|| null
|
||||
}
|
||||
|
||||
const loadTenantExtensions = async (tenantId: number | null) => {
|
||||
if (!tenantId) return []
|
||||
|
||||
return await server.db
|
||||
.select()
|
||||
.from(telephonyExtensions)
|
||||
.where(eq(telephonyExtensions.tenantId, tenantId))
|
||||
}
|
||||
|
||||
const loadExtensionDialTargets = async (tenantId: number, extensions: any[]) => {
|
||||
const targetsById = new Map<string, string[]>()
|
||||
const userExtensionByUserId = new Map<string, string>()
|
||||
|
||||
for (const extension of extensions) {
|
||||
if (!extension.enabled) continue
|
||||
|
||||
if (extension.targetType === "user" && extension.targetUserId) {
|
||||
userExtensionByUserId.set(extension.targetUserId, extension.extension)
|
||||
targetsById.set(extension.id, [extension.extension])
|
||||
}
|
||||
}
|
||||
|
||||
const profileRows = await server.db
|
||||
.select({
|
||||
id: authProfiles.id,
|
||||
userId: authProfiles.user_id,
|
||||
branchId: authProfiles.branch_id,
|
||||
})
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
eq(authProfiles.active, true)
|
||||
))
|
||||
|
||||
const profileUserByProfileId = new Map<string, string>()
|
||||
for (const profile of profileRows) {
|
||||
if (profile.id && profile.userId) {
|
||||
profileUserByProfileId.set(profile.id, profile.userId)
|
||||
}
|
||||
}
|
||||
|
||||
const profileIds = profileRows.map((profile) => profile.id).filter(Boolean)
|
||||
const teamRows = profileIds.length
|
||||
? await server.db
|
||||
.select()
|
||||
.from(authProfileTeams)
|
||||
.where(inArray(authProfileTeams.profile_id, profileIds))
|
||||
: []
|
||||
const branchRows = profileIds.length
|
||||
? await server.db
|
||||
.select()
|
||||
.from(authProfileBranches)
|
||||
.where(inArray(authProfileBranches.profile_id, profileIds))
|
||||
: []
|
||||
|
||||
for (const extension of extensions) {
|
||||
if (!extension.enabled || extension.targetType === "user") continue
|
||||
|
||||
const memberUserIds = new Set<string>()
|
||||
|
||||
if (extension.targetType === "team" && extension.targetTeamId) {
|
||||
for (const row of teamRows) {
|
||||
if (row.team_id !== extension.targetTeamId) continue
|
||||
const userId = profileUserByProfileId.get(row.profile_id)
|
||||
if (userId) memberUserIds.add(userId)
|
||||
}
|
||||
}
|
||||
|
||||
if (extension.targetType === "branch" && extension.targetBranchId) {
|
||||
for (const profile of profileRows) {
|
||||
if (profile.branchId === extension.targetBranchId && profile.userId) {
|
||||
memberUserIds.add(profile.userId)
|
||||
}
|
||||
}
|
||||
for (const row of branchRows) {
|
||||
if (row.branch_id !== extension.targetBranchId) continue
|
||||
const userId = profileUserByProfileId.get(row.profile_id)
|
||||
if (userId) memberUserIds.add(userId)
|
||||
}
|
||||
}
|
||||
|
||||
const dialTargets = Array.from(memberUserIds)
|
||||
.map((userId) => userExtensionByUserId.get(userId))
|
||||
.filter(Boolean) as string[]
|
||||
|
||||
targetsById.set(extension.id, Array.from(new Set(dialTargets)))
|
||||
}
|
||||
|
||||
return targetsById
|
||||
}
|
||||
|
||||
const sanitizeExtension = (extension: any) => ({
|
||||
id: extension.id,
|
||||
tenantId: extension.tenantId,
|
||||
targetType: extension.targetType,
|
||||
targetUserId: extension.targetUserId,
|
||||
targetTeamId: extension.targetTeamId,
|
||||
targetBranchId: extension.targetBranchId,
|
||||
extension: extension.extension,
|
||||
displayName: extension.displayName || "",
|
||||
sipUsername: extension.sipUsername || extension.extension,
|
||||
sipPasswordConfigured: Boolean(extension.sipPassword),
|
||||
enabled: Boolean(extension.enabled),
|
||||
createdAt: extension.createdAt,
|
||||
updatedAt: extension.updatedAt,
|
||||
})
|
||||
|
||||
const externalTelephonyConfig = async (tenantId: number | null) => {
|
||||
const trunk = await loadActiveTenantTrunk(tenantId)
|
||||
if (trunk) {
|
||||
@@ -492,6 +715,7 @@ export default async function telephonyRoutes(server: FastifyInstance) {
|
||||
enabled: trunk.enabled,
|
||||
provider: trunk.provider,
|
||||
inboundExtension: trunk.inboundExtension,
|
||||
defaultRouteExtensionId: trunk.defaultRouteExtensionId,
|
||||
outboundPrefix: trunk.outboundPrefix,
|
||||
registrar: trunk.registrar,
|
||||
externalSignalingAddress: trunk.externalSignalingAddress,
|
||||
@@ -595,6 +819,7 @@ export default async function telephonyRoutes(server: FastifyInstance) {
|
||||
authUser: bodyString(body, "authUser"),
|
||||
callerId: bodyString(body, "callerId"),
|
||||
inboundExtension: bodyString(body, "inboundExtension") || "1001",
|
||||
defaultRouteExtensionId: bodyString(body, "defaultRouteExtensionId"),
|
||||
outboundPrefix: bodyString(body, "outboundPrefix") || "0",
|
||||
externalSignalingAddress: bodyString(body, "externalSignalingAddress"),
|
||||
externalMediaAddress: bodyString(body, "externalMediaAddress"),
|
||||
@@ -671,7 +896,13 @@ export default async function telephonyRoutes(server: FastifyInstance) {
|
||||
})
|
||||
}
|
||||
|
||||
const files = await writeAsteriskTrunkConfig(trunk)
|
||||
const extensions = await loadTenantExtensions(tenantId)
|
||||
const dialTargetsById = await loadExtensionDialTargets(tenantId, extensions)
|
||||
const defaultRoute = extensions.find((extension) => extension.id === trunk.defaultRouteExtensionId)
|
||||
const files = await writeAsteriskTrunkConfig({
|
||||
...trunk,
|
||||
inboundExtension: defaultRoute?.extension || trunk.inboundExtension,
|
||||
}, extensions, dialTargetsById)
|
||||
let reload: any = null
|
||||
let status: any = null
|
||||
let warning: string | null = null
|
||||
@@ -710,6 +941,192 @@ export default async function telephonyRoutes(server: FastifyInstance) {
|
||||
}
|
||||
})
|
||||
|
||||
server.get("/telephony/extensions/options", async (req) => {
|
||||
const tenantId = requireTenant(req.user.tenant_id)
|
||||
|
||||
const tenantUsers = await server.db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
firstName: authProfiles.first_name,
|
||||
lastName: authProfiles.last_name,
|
||||
})
|
||||
.from(authProfiles)
|
||||
.innerJoin(authUsers, eq(authUsers.id, authProfiles.user_id))
|
||||
.where(and(
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
eq(authProfiles.active, true)
|
||||
))
|
||||
|
||||
const tenantTeams = await server.db
|
||||
.select({
|
||||
id: teams.id,
|
||||
name: teams.name,
|
||||
})
|
||||
.from(teams)
|
||||
.where(and(
|
||||
eq(teams.tenant, tenantId),
|
||||
eq(teams.archived, false)
|
||||
))
|
||||
|
||||
const tenantBranches = await server.db
|
||||
.select({
|
||||
id: branches.id,
|
||||
name: branches.name,
|
||||
number: branches.number,
|
||||
})
|
||||
.from(branches)
|
||||
.where(and(
|
||||
eq(branches.tenant, tenantId),
|
||||
eq(branches.archived, false)
|
||||
))
|
||||
|
||||
return {
|
||||
users: tenantUsers.map((user) => ({
|
||||
id: user.id,
|
||||
label: [user.firstName, user.lastName].filter(Boolean).join(" ") || user.email,
|
||||
description: user.email,
|
||||
})),
|
||||
teams: tenantTeams.map((team) => ({
|
||||
id: team.id,
|
||||
label: team.name,
|
||||
})),
|
||||
branches: tenantBranches.map((branch) => ({
|
||||
id: branch.id,
|
||||
label: branch.number ? `${branch.number} - ${branch.name}` : branch.name,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
server.get("/telephony/extensions", async (req) => {
|
||||
const tenantId = requireTenant(req.user.tenant_id)
|
||||
const extensions = await loadTenantExtensions(tenantId)
|
||||
const dialTargetsById = await loadExtensionDialTargets(tenantId, extensions)
|
||||
|
||||
return {
|
||||
rows: extensions
|
||||
.sort((a, b) => a.extension.localeCompare(b.extension, "de"))
|
||||
.map((extension) => ({
|
||||
...sanitizeExtension(extension),
|
||||
dialTargets: dialTargetsById.get(extension.id) || [],
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/telephony/extensions", async (req, reply) => {
|
||||
const tenantId = requireTenant(req.user.tenant_id)
|
||||
const body = (req.body || {}) as any
|
||||
const targetType = normalizeExtensionTargetType(bodyString(body, "targetType"))
|
||||
const extension = normalizeExtension(bodyString(body, "extension"))
|
||||
|
||||
if (!extension) {
|
||||
return reply.code(400).send({ error: "Nebenstelle ist erforderlich." })
|
||||
}
|
||||
|
||||
const targetUserId = targetType === "user" ? bodyString(body, "targetUserId") : null
|
||||
const targetTeamId = targetType === "team" ? bodyNumber(body, "targetTeamId") : null
|
||||
const targetBranchId = targetType === "branch" ? bodyNumber(body, "targetBranchId") : null
|
||||
|
||||
if (
|
||||
(targetType === "user" && !targetUserId)
|
||||
|| (targetType === "team" && !targetTeamId)
|
||||
|| (targetType === "branch" && !targetBranchId)
|
||||
) {
|
||||
return reply.code(400).send({ error: "Ziel der Nebenstelle ist erforderlich." })
|
||||
}
|
||||
|
||||
const sipPassword = bodyString(body, "sipPassword") || generateSipPassword()
|
||||
const now = new Date()
|
||||
const [created] = await server.db
|
||||
.insert(telephonyExtensions)
|
||||
.values({
|
||||
tenantId,
|
||||
targetType,
|
||||
targetUserId,
|
||||
targetTeamId,
|
||||
targetBranchId,
|
||||
extension,
|
||||
displayName: bodyString(body, "displayName"),
|
||||
sipUsername: bodyString(body, "sipUsername") || extension,
|
||||
sipPassword,
|
||||
enabled: body?.enabled !== false,
|
||||
createdBy: req.user.user_id,
|
||||
updatedAt: now,
|
||||
updatedBy: req.user.user_id,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return reply.code(201).send(sanitizeExtension(created))
|
||||
})
|
||||
|
||||
server.put("/telephony/extensions/: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 targetType = normalizeExtensionTargetType(bodyString(body, "targetType"))
|
||||
const extension = normalizeExtension(bodyString(body, "extension"))
|
||||
|
||||
if (!extension) {
|
||||
return reply.code(400).send({ error: "Nebenstelle ist erforderlich." })
|
||||
}
|
||||
|
||||
const existing = await server.db
|
||||
.select()
|
||||
.from(telephonyExtensions)
|
||||
.where(and(
|
||||
eq(telephonyExtensions.tenantId, tenantId),
|
||||
eq(telephonyExtensions.id, params.id)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
if (!existing[0]) {
|
||||
return reply.code(404).send({ error: "Nebenstelle nicht gefunden." })
|
||||
}
|
||||
|
||||
const sipPassword = bodyString(body, "sipPassword")
|
||||
const [updated] = await server.db
|
||||
.update(telephonyExtensions)
|
||||
.set({
|
||||
targetType,
|
||||
targetUserId: targetType === "user" ? bodyString(body, "targetUserId") : null,
|
||||
targetTeamId: targetType === "team" ? bodyNumber(body, "targetTeamId") : null,
|
||||
targetBranchId: targetType === "branch" ? bodyNumber(body, "targetBranchId") : null,
|
||||
extension,
|
||||
displayName: bodyString(body, "displayName"),
|
||||
sipUsername: bodyString(body, "sipUsername") || extension,
|
||||
...(sipPassword ? { sipPassword } : {}),
|
||||
enabled: body?.enabled !== false,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: req.user.user_id,
|
||||
})
|
||||
.where(and(
|
||||
eq(telephonyExtensions.tenantId, tenantId),
|
||||
eq(telephonyExtensions.id, params.id)
|
||||
))
|
||||
.returning()
|
||||
|
||||
return sanitizeExtension(updated)
|
||||
})
|
||||
|
||||
server.delete("/telephony/extensions/:id", async (req, reply) => {
|
||||
const tenantId = requireTenant(req.user.tenant_id)
|
||||
const params = req.params as { id: string }
|
||||
|
||||
const deleted = await server.db
|
||||
.delete(telephonyExtensions)
|
||||
.where(and(
|
||||
eq(telephonyExtensions.tenantId, tenantId),
|
||||
eq(telephonyExtensions.id, params.id)
|
||||
))
|
||||
.returning()
|
||||
|
||||
if (!deleted[0]) {
|
||||
return reply.code(404).send({ error: "Nebenstelle nicht gefunden." })
|
||||
}
|
||||
|
||||
return { deleted: true }
|
||||
})
|
||||
|
||||
server.get("/telephony/calls", async (req) => {
|
||||
const tenantId = requireTenant(req.user.tenant_id)
|
||||
const limit = Math.min(
|
||||
|
||||
Reference in New Issue
Block a user