KI-AGENT: Telefonie Nebenstellen in Einstellungen integrieren
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user