KI-AGENT: Nebenstellen und Standardroute für Telefonie ergänzen

This commit is contained in:
2026-05-22 15:24:10 +02:00
parent bbbdc4d2ae
commit b44c8d453a
9 changed files with 941 additions and 4 deletions

View File

@@ -0,0 +1,92 @@
CREATE TABLE IF NOT EXISTS "telephony_extensions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"target_type" text NOT NULL,
"target_user_id" uuid,
"target_team_id" bigint,
"target_branch_id" bigint,
"extension" text NOT NULL,
"display_name" text,
"sip_username" text,
"sip_password" text,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"created_by" uuid,
"updated_by" uuid
);
ALTER TABLE "telephony_trunks"
ADD COLUMN IF NOT EXISTS "default_route_extension_id" uuid;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_tenant_id_tenants_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_target_user_id_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_target_user_id_auth_users_id_fk"
FOREIGN KEY ("target_user_id") REFERENCES "public"."auth_users"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_target_team_id_teams_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_target_team_id_teams_id_fk"
FOREIGN KEY ("target_team_id") REFERENCES "public"."teams"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_target_branch_id_branches_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_target_branch_id_branches_id_fk"
FOREIGN KEY ("target_branch_id") REFERENCES "public"."branches"("id")
ON DELETE cascade ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_created_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_created_by_auth_users_id_fk"
FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_extensions_updated_by_auth_users_id_fk'
) THEN
ALTER TABLE "telephony_extensions"
ADD CONSTRAINT "telephony_extensions_updated_by_auth_users_id_fk"
FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id")
ON DELETE no action ON UPDATE no action;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'telephony_trunks_default_route_extension_id_telephony_extensions_id_fk'
) THEN
ALTER TABLE "telephony_trunks"
ADD CONSTRAINT "telephony_trunks_default_route_extension_id_telephony_extensions_id_fk"
FOREIGN KEY ("default_route_extension_id") REFERENCES "public"."telephony_extensions"("id")
ON DELETE set null ON UPDATE no action;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "telephony_extensions_tenant_extension_idx"
ON "telephony_extensions" USING btree ("tenant_id", "extension");
CREATE INDEX IF NOT EXISTS "telephony_extensions_tenant_target_idx"
ON "telephony_extensions" USING btree ("tenant_id", "target_type");

View File

@@ -330,6 +330,13 @@
"when": 1780167600000,
"tag": "0046_telephony_trunk_nat",
"breakpoints": true
},
{
"idx": 47,
"version": "7",
"when": 1780171200000,
"tag": "0047_telephony_extensions",
"breakpoints": true
}
]
}

View File

@@ -76,6 +76,7 @@ export * from "./tasks"
export * from "./teams"
export * from "./taxtypes"
export * from "./telephony_calls"
export * from "./telephony_extensions"
export * from "./telephony_trunks"
export * from "./tenants"
export * from "./texttemplates"

View File

@@ -0,0 +1,53 @@
import {
pgTable,
uuid,
bigint,
text,
timestamp,
boolean,
index,
uniqueIndex,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { teams } from "./teams"
import { branches } from "./branches"
export const telephonyExtensions = pgTable(
"telephony_extensions",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
targetType: text("target_type").notNull(),
targetUserId: uuid("target_user_id").references(() => authUsers.id, { onDelete: "cascade" }),
targetTeamId: bigint("target_team_id", { mode: "number" }).references(() => teams.id, { onDelete: "cascade" }),
targetBranchId: bigint("target_branch_id", { mode: "number" }).references(() => branches.id, { onDelete: "cascade" }),
extension: text("extension").notNull(),
displayName: text("display_name"),
sipUsername: text("sip_username"),
sipPassword: text("sip_password"),
enabled: boolean("enabled").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
createdBy: uuid("created_by").references(() => authUsers.id),
updatedBy: uuid("updated_by").references(() => authUsers.id),
},
(table) => ({
tenantExtensionIdx: uniqueIndex("telephony_extensions_tenant_extension_idx")
.on(table.tenantId, table.extension),
tenantTargetIdx: index("telephony_extensions_tenant_target_idx")
.on(table.tenantId, table.targetType),
})
)
export type TelephonyExtension = typeof telephonyExtensions.$inferSelect
export type NewTelephonyExtension = typeof telephonyExtensions.$inferInsert

View File

@@ -10,6 +10,7 @@ import {
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { telephonyExtensions } from "./telephony_extensions"
export const telephonyTrunks = pgTable(
"telephony_trunks",
@@ -29,6 +30,7 @@ export const telephonyTrunks = pgTable(
password: text("password"),
callerId: text("caller_id"),
inboundExtension: text("inbound_extension").notNull().default("1001"),
defaultRouteExtensionId: uuid("default_route_extension_id").references(() => telephonyExtensions.id, { onDelete: "set null" }),
outboundPrefix: text("outbound_prefix").notNull().default("0"),
externalSignalingAddress: text("external_signaling_address"),
externalMediaAddress: text("external_media_address"),

View File

@@ -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(