KI-AGENT: Telefonie Nebenstellen in Einstellungen integrieren

This commit is contained in:
2026-05-22 15:55:06 +02:00
parent da9cad1513
commit 520052e71a
12 changed files with 922 additions and 780 deletions

View File

@@ -3,7 +3,7 @@ import { promises as fs } from "node:fs"
import net from "node:net"
import path from "node:path"
import { randomBytes } from "node:crypto"
import { and, desc, eq, inArray } from "drizzle-orm"
import { and, desc, eq, inArray, ne } from "drizzle-orm"
import {
authProfileBranches,
authProfiles,
@@ -25,21 +25,15 @@ 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"
process.env.TELEPHONY_ASTERISK_HTTP_URL || ""
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`,
]))
return configuredUrl ? [configuredUrl] : []
}
const publicAsteriskWsUrl = () =>
process.env.TELEPHONY_ASTERISK_WS_URL || `ws://localhost:${process.env.TELEPHONY_DEV_WS_PORT || "8088"}/ws`
process.env.TELEPHONY_ASTERISK_WS_URL || ""
const defaultAsteriskGeneratedDir = () => {
const cwd = process.cwd()
@@ -50,7 +44,7 @@ const asteriskGeneratedDir = () =>
process.env.TELEPHONY_ASTERISK_GENERATED_DIR || defaultAsteriskGeneratedDir()
const defaultAsteriskAmiHost = () =>
process.env.NODE_ENV === "production" ? "asterisk-dev" : "127.0.0.1"
process.env.NODE_ENV === "production" ? "" : "127.0.0.1"
const asteriskAmiConfig = () => ({
host: process.env.TELEPHONY_ASTERISK_AMI_HOST || defaultAsteriskAmiHost(),
@@ -62,19 +56,6 @@ const asteriskAmiConfig = () => ({
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 trunkProviders = {
telekom: {
key: "telekom",
@@ -498,6 +479,9 @@ const writeAsteriskTrunkConfig = async (trunk: any, extensions: any[] = [], dial
const runAsteriskAmiCommand = async (command: string, timeoutMs = 5000) => {
const config = asteriskAmiConfig()
if (!config.host) {
throw new Error("Asterisk-AMI ist nicht konfiguriert.")
}
return await new Promise<{ command: string, ok: boolean, raw: string }>((resolve, reject) => {
const socket = net.createConnection(config.port, config.host)
@@ -708,6 +692,59 @@ export default async function telephonyRoutes(server: FastifyInstance) {
updatedAt: extension.updatedAt,
})
const extensionTargetWhere = (tenantId: number, targetType: string, targetId: string | number | null) => {
if (targetType === "team") {
return and(
eq(telephonyExtensions.tenantId, tenantId),
eq(telephonyExtensions.targetType, "team"),
eq(telephonyExtensions.targetTeamId, Number(targetId))
)
}
if (targetType === "branch") {
return and(
eq(telephonyExtensions.tenantId, tenantId),
eq(telephonyExtensions.targetType, "branch"),
eq(telephonyExtensions.targetBranchId, Number(targetId))
)
}
return and(
eq(telephonyExtensions.tenantId, tenantId),
eq(telephonyExtensions.targetType, "user"),
eq(telephonyExtensions.targetUserId, String(targetId))
)
}
const resolveExtensionTargetId = (targetType: string, body: any) => {
if (targetType === "team") return bodyNumber(body, "targetTeamId") ?? bodyNumber(body, "targetId")
if (targetType === "branch") return bodyNumber(body, "targetBranchId") ?? bodyNumber(body, "targetId")
return bodyString(body, "targetUserId") || bodyString(body, "targetId")
}
const validateExtensionDuplicate = async (
tenantId: number,
extension: string,
currentExtensionId: string | null = null
) => {
const conditions = [
eq(telephonyExtensions.tenantId, tenantId),
eq(telephonyExtensions.extension, extension),
]
if (currentExtensionId) {
conditions.push(ne(telephonyExtensions.id, currentExtensionId))
}
const existing = await server.db
.select({ id: telephonyExtensions.id })
.from(telephonyExtensions)
.where(and(...conditions))
.limit(1)
return existing[0] ? "Diese Nebenstelle ist bereits vergeben." : null
}
const externalTelephonyConfig = async (tenantId: number | null) => {
const trunk = await loadActiveTenantTrunk(tenantId)
if (trunk) {
@@ -731,14 +768,34 @@ export default async function telephonyRoutes(server: FastifyInstance) {
return envExternalTelephonyConfig()
}
const loadUserSipAccounts = async (tenantId: number | null, userId: string | null | undefined) => {
if (!tenantId || !userId) return []
const extensions = await server.db
.select()
.from(telephonyExtensions)
.where(and(
eq(telephonyExtensions.tenantId, tenantId),
eq(telephonyExtensions.targetType, "user"),
eq(telephonyExtensions.targetUserId, userId),
eq(telephonyExtensions.enabled, true)
))
return extensions.map((extension) => ({
extension: extension.extension,
password: extension.sipPassword,
displayName: extension.displayName || `Nebenstelle ${extension.extension}`,
})).filter((account) => account.extension && account.password)
}
server.get("/telephony/config", async (req) => ({
enabled: telephonyEnabled(),
provider: "asterisk",
mode: "local-test",
mode: "asterisk",
sipDomain: sipDomain(),
sipWebSocketUrl: publicAsteriskWsUrl(),
echoExtension: process.env.TELEPHONY_ECHO_EXTENSION || "600",
testAccounts: testAccounts(),
accounts: await loadUserSipAccounts(req.user?.tenant_id || null, req.user?.user_id),
external: await externalTelephonyConfig(req.user?.tenant_id || null),
}))
@@ -787,7 +844,9 @@ export default async function telephonyRoutes(server: FastifyInstance) {
reachable: false,
statusUrl: urls[0],
attempts,
message: lastError?.name === "AbortError"
message: !urls.length
? "Asterisk-Status-URL ist nicht konfiguriert."
: lastError?.name === "AbortError"
? "Asterisk-Statusabfrage ist abgelaufen."
: (lastError?.message || "Asterisk ist nicht erreichbar."),
}
@@ -1002,17 +1061,166 @@ export default async function telephonyRoutes(server: FastifyInstance) {
const tenantId = requireTenant(req.user.tenant_id)
const extensions = await loadTenantExtensions(tenantId)
const dialTargetsById = await loadExtensionDialTargets(tenantId, extensions)
const options = await (async () => {
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: new Map(tenantUsers.map((user) => [
user.id,
[user.firstName, user.lastName].filter(Boolean).join(" ") || user.email,
])),
teams: new Map(tenantTeams.map((team) => [team.id, team.name])),
branches: new Map(tenantBranches.map((branch) => [
branch.id,
branch.number ? `${branch.number} - ${branch.name}` : branch.name,
])),
}
})()
return {
rows: extensions
.sort((a, b) => a.extension.localeCompare(b.extension, "de"))
.map((extension) => ({
...sanitizeExtension(extension),
targetLabel: extension.targetType === "team"
? options.teams.get(extension.targetTeamId)
: extension.targetType === "branch"
? options.branches.get(extension.targetBranchId)
: options.users.get(extension.targetUserId),
dialTargets: dialTargetsById.get(extension.id) || [],
})),
}
})
server.get("/telephony/extensions/target", async (req) => {
const tenantId = requireTenant(req.user.tenant_id)
const query = req.query as { targetType?: string; targetId?: string }
const targetType = normalizeExtensionTargetType(query.targetType)
const targetId = targetType === "user" ? query.targetId : Number(query.targetId)
if (!targetId || (targetType !== "user" && !Number.isFinite(Number(targetId)))) {
return { extension: null }
}
const existing = await server.db
.select()
.from(telephonyExtensions)
.where(extensionTargetWhere(tenantId, targetType, targetId))
.limit(1)
return { extension: existing[0] ? sanitizeExtension(existing[0]) : null }
})
server.put("/telephony/extensions/target", async (req, reply) => {
const tenantId = requireTenant(req.user.tenant_id)
const body = (req.body || {}) as any
const targetType = normalizeExtensionTargetType(bodyString(body, "targetType"))
const targetId = resolveExtensionTargetId(targetType, body)
const extension = normalizeExtension(bodyString(body, "extension"))
if (!targetId) {
return reply.code(400).send({ error: "Ziel der Nebenstelle ist erforderlich." })
}
if (!extension) {
return reply.code(400).send({ error: "Nebenstelle ist erforderlich." })
}
const existing = await server.db
.select()
.from(telephonyExtensions)
.where(extensionTargetWhere(tenantId, targetType, targetId))
.limit(1)
const duplicateError = await validateExtensionDuplicate(tenantId, extension, existing[0]?.id || null)
if (duplicateError) {
return reply.code(409).send({ error: duplicateError })
}
const displayName = bodyString(body, "displayName")
const now = new Date()
if (existing[0]) {
const [updated] = await server.db
.update(telephonyExtensions)
.set({
extension,
displayName,
sipUsername: extension,
enabled: body?.enabled !== false,
updatedAt: now,
updatedBy: req.user.user_id,
})
.where(and(
eq(telephonyExtensions.tenantId, tenantId),
eq(telephonyExtensions.id, existing[0].id)
))
.returning()
return sanitizeExtension(updated)
}
const [created] = await server.db
.insert(telephonyExtensions)
.values({
tenantId,
targetType,
targetUserId: targetType === "user" ? String(targetId) : null,
targetTeamId: targetType === "team" ? Number(targetId) : null,
targetBranchId: targetType === "branch" ? Number(targetId) : null,
extension,
displayName,
sipUsername: extension,
sipPassword: generateSipPassword(),
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.delete("/telephony/extensions/target", async (req, reply) => {
const tenantId = requireTenant(req.user.tenant_id)
const query = req.query as { targetType?: string; targetId?: string }
const targetType = normalizeExtensionTargetType(query.targetType)
const targetId = targetType === "user" ? query.targetId : Number(query.targetId)
if (!targetId || (targetType !== "user" && !Number.isFinite(Number(targetId)))) {
return reply.code(400).send({ error: "Ziel der Nebenstelle ist erforderlich." })
}
await server.db
.delete(telephonyExtensions)
.where(extensionTargetWhere(tenantId, targetType, targetId))
return { deleted: true }
})
server.post("/telephony/extensions", async (req, reply) => {
const tenantId = requireTenant(req.user.tenant_id)
const body = (req.body || {}) as any
@@ -1023,6 +1231,11 @@ export default async function telephonyRoutes(server: FastifyInstance) {
return reply.code(400).send({ error: "Nebenstelle ist erforderlich." })
}
const duplicateError = await validateExtensionDuplicate(tenantId, extension)
if (duplicateError) {
return reply.code(409).send({ error: duplicateError })
}
const targetUserId = targetType === "user" ? bodyString(body, "targetUserId") : null
const targetTeamId = targetType === "team" ? bodyNumber(body, "targetTeamId") : null
const targetBranchId = targetType === "branch" ? bodyNumber(body, "targetBranchId") : null
@@ -1083,6 +1296,11 @@ export default async function telephonyRoutes(server: FastifyInstance) {
return reply.code(404).send({ error: "Nebenstelle nicht gefunden." })
}
const duplicateError = await validateExtensionDuplicate(tenantId, extension, params.id)
if (duplicateError) {
return reply.code(409).send({ error: duplicateError })
}
const sipPassword = bodyString(body, "sipPassword")
const [updated] = await server.db
.update(telephonyExtensions)