diff --git a/TODO.md b/TODO.md index 59e4ad3..653bd5e 100644 --- a/TODO.md +++ b/TODO.md @@ -173,7 +173,34 @@ múltiplas em sequência, não implementado - [ ] `dialplan.view`/`.manage` não existem — reusei `freeswitch.*` -## PHASE 11+ — ver `agente.md` seções 37 em diante (Call Center/mod_callcenter, +## PHASE 11 — mod_callcenter / Queues (agente.md secao 37, 50-51) +- [x] Investigado `help callcenter_config` real antes de codar: filas só + têm `load`/`unload`/`reload` (XML estático + reload, sem `queue add`); + agentes e tiers são 100% dinâmicos via comando ESL (`agent add`, + `tier add`) — próxima fase, sem arquivo nenhum +- [x] `queues` table (tenant-scoped, RLS) — strategy, moh/announce, + wait times, tier rules, discard/abandoned, skip-external-calls, + recording_enabled +- [x] `packages/telephony`: `buildQueueXml()` +- [x] `overrides/autoload_configs/callcenter.conf.xml` própria (zera + agents/tiers estáticos da vanilla, inclui + `callcenter_queues.conf.d/*.xml` via X-PRE-PROCESS) +- [x] `apps/api/src/queues`: CRUD (POST/GET/GET:id/DELETE), permissions + `queues.view`/`.manage` +- [x] `b2bcall-fs-config` (`queue-sync.ts`): 1 arquivo por fila (volume + compartilhado), sincroniza via Redis pub/sub (`b2bcall:queues:sync`) +- [x] Achado real, confirmado testando manualmente antes de escrever código: + `queue load` falha se o arquivo foi adicionado depois do boot — + precisa de `reloadxml` primeiro; depois disso, `queue reload` sozinho + serve tanto pra criar quanto atualizar +- [x] Testado ponta a ponta: criar fila (ROUND_ROBIN, maxWaitTime=120, + discardAbandonedAfter=90) → `callcenter_config queue list` mostra os + parâmetros corretos → deletar → lista volta vazia +- [ ] Agentes/Tiers/Pausas (secao 45-49) — próxima fase +- [ ] Monitoramento em tempo real (secao 54) — depende de WebSocket +- [ ] Quota de filas — depende de Plans/Entitlements + +## PHASE 12+ — ver `agente.md` seções 45 em diante (Agentes, Tiers, Pausas, Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests) --- diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b4ca535..63a3412 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,8 +4,9 @@ import { AuthModule } from "./auth/auth.module"; import { ExtensionsModule } from "./extensions/extensions.module"; import { TrunksModule } from "./trunks/trunks.module"; import { DialplanModule } from "./dialplan/dialplan.module"; +import { QueuesModule } from "./queues/queues.module"; @Module({ - imports: [HealthModule, AuthModule, ExtensionsModule, TrunksModule, DialplanModule], + imports: [HealthModule, AuthModule, ExtensionsModule, TrunksModule, DialplanModule, QueuesModule], }) export class AppModule {} diff --git a/apps/api/src/queues/dto/create-queue.dto.ts b/apps/api/src/queues/dto/create-queue.dto.ts new file mode 100644 index 0000000..d078ba0 --- /dev/null +++ b/apps/api/src/queues/dto/create-queue.dto.ts @@ -0,0 +1,88 @@ +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from "class-validator"; + +const STRATEGIES = [ + "LONGEST_IDLE_AGENT", + "ROUND_ROBIN", + "TOP_DOWN", + "AGENT_WITH_LEAST_TALK_TIME", + "AGENT_WITH_FEWEST_CALLS", + "SEQUENTIALLY_BY_AGENT_ORDER", + "RING_ALL", + "RING_PROGRESSIVELY", +] as const; + +export class CreateQueueDto { + @IsString() + @MaxLength(80) + name!: string; + + @IsOptional() + @IsString() + @MaxLength(255) + description?: string; + + @IsOptional() + @IsIn(STRATEGIES) + strategy?: (typeof STRATEGIES)[number]; + + @IsOptional() + @IsString() + @MaxLength(255) + mohSound?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + announceSound?: string; + + @IsOptional() + @IsInt() + @Min(0) + @Max(3600) + announceFrequency?: number; + + @IsOptional() + @IsInt() + @Min(0) + @Max(3600) + maxWaitTime?: number; + + @IsOptional() + @IsInt() + @Min(0) + @Max(3600) + maxWaitTimeWithNoAgent?: number; + + @IsOptional() + @IsString() + @MaxLength(80) + agentNoAnswerStatus?: string; + + @IsOptional() + @IsBoolean() + tierRulesApply?: boolean; + + @IsOptional() + @IsInt() + @Min(1) + @Max(3600) + tierRuleWaitSecond?: number; + + @IsOptional() + @IsInt() + @Min(0) + @Max(3600) + discardAbandonedAfter?: number; + + @IsOptional() + @IsBoolean() + abandonedResumeAllowed?: boolean; + + @IsOptional() + @IsBoolean() + skipAgentsWithExternalCalls?: boolean; + + @IsOptional() + @IsBoolean() + recordingEnabled?: boolean; +} diff --git a/apps/api/src/queues/queues.controller.ts b/apps/api/src/queues/queues.controller.ts new file mode 100644 index 0000000..89bf821 --- /dev/null +++ b/apps/api/src/queues/queues.controller.ts @@ -0,0 +1,125 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + Post, + UseGuards, +} from "@nestjs/common"; +import { getPrismaClient, withTenantContext } from "@b2bcall/database"; +import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; +import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; +import { PermissionGuard } from "../common/guards/permission.guard"; +import { RequirePermission } from "../common/decorators/require-permission.decorator"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { getRedisClient } from "../common/redis"; +import { CreateQueueDto } from "./dto/create-queue.dto"; + +const QUEUES_SYNC_CHANNEL = "b2bcall:queues:sync"; + +async function notifyQueuesChanged(): Promise { + await getRedisClient().publish(QUEUES_SYNC_CHANNEL, "sync"); +} + +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller("queues") +export class QueuesController { + @RequirePermission("queues.manage") + @Post() + async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateQueueDto) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const queue = await withTenantContext(prisma, tenantId, (tx) => + tx.queue.create({ + data: { + tenantId, + name: dto.name, + description: dto.description, + strategy: dto.strategy ?? "LONGEST_IDLE_AGENT", + mohSound: dto.mohSound, + announceSound: dto.announceSound, + announceFrequency: dto.announceFrequency ?? 0, + maxWaitTime: dto.maxWaitTime ?? 0, + maxWaitTimeWithNoAgent: dto.maxWaitTimeWithNoAgent ?? 0, + agentNoAnswerStatus: dto.agentNoAnswerStatus, + tierRulesApply: dto.tierRulesApply ?? false, + tierRuleWaitSecond: dto.tierRuleWaitSecond ?? 300, + discardAbandonedAfter: dto.discardAbandonedAfter ?? 60, + abandonedResumeAllowed: dto.abandonedResumeAllowed ?? false, + skipAgentsWithExternalCalls: dto.skipAgentsWithExternalCalls ?? true, + recordingEnabled: dto.recordingEnabled ?? false, + }, + }), + ); + + await recordAuditEvent(prisma, { + action: "QUEUE_CREATE", + tenantId, + userId: user.sub, + entityType: "queue", + entityId: queue.id, + after: { name: queue.name, strategy: queue.strategy }, + }); + + await notifyQueuesChanged(); + + return queue; + } + + @RequirePermission("queues.view") + @Get() + async list(@CurrentUser() user: AccessTokenClaims) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + return withTenantContext(prisma, tenantId, (tx) => + tx.queue.findMany({ where: { deletedAt: null }, orderBy: { name: "asc" } }), + ); + } + + @RequirePermission("queues.view") + @Get(":id") + async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + const queue = await withTenantContext(prisma, tenantId, (tx) => + tx.queue.findFirst({ where: { id, deletedAt: null } }), + ); + if (!queue) { + throw new NotFoundException(); + } + return queue; + } + + @RequirePermission("queues.manage") + @Delete(":id") + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const result = await withTenantContext(prisma, tenantId, (tx) => + tx.queue.updateMany({ + where: { id, deletedAt: null }, + data: { deletedAt: new Date(), enabled: false }, + }), + ); + if (result.count === 0) { + throw new NotFoundException(); + } + + await recordAuditEvent(prisma, { + action: "QUEUE_DELETE", + tenantId, + userId: user.sub, + entityType: "queue", + entityId: id, + }); + + await notifyQueuesChanged(); + } +} diff --git a/apps/api/src/queues/queues.module.ts b/apps/api/src/queues/queues.module.ts new file mode 100644 index 0000000..28cec6b --- /dev/null +++ b/apps/api/src/queues/queues.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { QueuesController } from "./queues.controller"; + +@Module({ + controllers: [QueuesController], +}) +export class QueuesModule {} diff --git a/apps/freeswitch-config/src/main.ts b/apps/freeswitch-config/src/main.ts index 49593f6..543000a 100644 --- a/apps/freeswitch-config/src/main.ts +++ b/apps/freeswitch-config/src/main.ts @@ -7,10 +7,12 @@ import { decryptSecret } from "@b2bcall/shared"; import { buildDirectoryUserXml, NOT_FOUND_XML } from "@b2bcall/telephony"; import { createLogger } from "@b2bcall/shared"; import { syncTrunks } from "./trunk-sync"; +import { syncQueues } from "./queue-sync"; const logger = createLogger("b2bcall-fs-config"); const TRUNKS_SYNC_CHANNEL = "b2bcall:trunks:sync"; +const QUEUES_SYNC_CHANNEL = "b2bcall:queues:sync"; function requireEnv(name: string): string { const value = process.env[name]; @@ -172,12 +174,17 @@ async function main() { // criados enquanto este servico estava fora do ar. const subscriber = new Redis(process.env.REDIS_URL!); subscriber.on("error", (err) => logger.error("erro na conexao Redis (subscriber)", { error: String(err) })); - await subscriber.subscribe(TRUNKS_SYNC_CHANNEL); - subscriber.on("message", (_channel, _msg) => { - syncTrunks().catch((err) => logger.error("falha ao sincronizar trunks", { error: String(err) })); + await subscriber.subscribe(TRUNKS_SYNC_CHANNEL, QUEUES_SYNC_CHANNEL); + subscriber.on("message", (channel, _msg) => { + if (channel === TRUNKS_SYNC_CHANNEL) { + syncTrunks().catch((err) => logger.error("falha ao sincronizar trunks", { error: String(err) })); + } else if (channel === QUEUES_SYNC_CHANNEL) { + syncQueues().catch((err) => logger.error("falha ao sincronizar filas", { error: String(err) })); + } }); syncTrunks().catch((err) => logger.error("falha na sincronizacao inicial de trunks", { error: String(err) })); + syncQueues().catch((err) => logger.error("falha na sincronizacao inicial de filas", { error: String(err) })); } main().catch((err) => { diff --git a/apps/freeswitch-config/src/queue-sync.ts b/apps/freeswitch-config/src/queue-sync.ts new file mode 100644 index 0000000..5eea65f --- /dev/null +++ b/apps/freeswitch-config/src/queue-sync.ts @@ -0,0 +1,122 @@ +import { mkdir, readdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { getPrismaClient, withTenantContext } from "@b2bcall/database"; +import { buildQueueXml, FreeSwitchTelephonyProvider } from "@b2bcall/telephony"; +import { createLogger } from "@b2bcall/shared"; + +const logger = createLogger("b2bcall-fs-config"); + +const QUEUES_DIR = process.env.CALLCENTER_QUEUES_DIR ?? "/callcenter-queues"; + +let provider: FreeSwitchTelephonyProvider | undefined; + +function getProvider(): FreeSwitchTelephonyProvider { + if (!provider) { + provider = new FreeSwitchTelephonyProvider({ + host: process.env.ESL_HOST ?? "freeswitch", + port: Number(process.env.ESL_PORT ?? 8021), + password: process.env.ESL_PASSWORD ?? "", + logger: { + debug: () => {}, + info: (msg) => logger.debug(msg), + error: (msg, data) => logger.error(msg, { detail: data }), + }, + }); + provider.connect(); + } + return provider; +} + +/** + * Regera um arquivo de fila por tenant/fila habilitada (agente.md secao + * 50-51), sinaliza o FreeSWITCH a reler o XML e recarrega cada fila. + * + * Sequência confirmada manualmente contra o FreeSWITCH: `queue load` sozinho + * falha ("Invalid Queue not found!") se o arquivo foi adicionado depois do + * boot — precisa de `reloadxml` primeiro pra repopular a árvore XML em + * memória a partir do disco. Depois disso, `queue reload ` sozinho + * já serve tanto pra criar quanto atualizar (não precisa distinguir + * load/reload). Ver docs/QUEUES.md. + */ +export async function syncQueues(): Promise { + const prisma = getPrismaClient(); + const tenants = await prisma.tenant.findMany({ where: { status: "ACTIVE" } }); + + const desired: Array<{ file: string; queueName: string }> = []; + const files: Array<{ name: string; xml: string }> = []; + + for (const tenant of tenants) { + if (!tenant.telephonyDomain) continue; + + const queues = await withTenantContext(prisma, tenant.id, (tx) => + tx.queue.findMany({ where: { tenantId: tenant.id, enabled: true, deletedAt: null } }), + ); + + for (const queue of queues) { + const queueName = `${queue.id}@${tenant.telephonyDomain}`; + const xml = buildQueueXml({ + queueName, + strategy: queue.strategy, + mohSound: queue.mohSound ?? undefined, + announceSound: queue.announceSound ?? undefined, + announceFrequency: queue.announceFrequency, + maxWaitTime: queue.maxWaitTime, + maxWaitTimeWithNoAgent: queue.maxWaitTimeWithNoAgent, + agentNoAnswerStatus: queue.agentNoAnswerStatus ?? undefined, + tierRulesApply: queue.tierRulesApply, + tierRuleWaitSecond: queue.tierRuleWaitSecond, + discardAbandonedAfter: queue.discardAbandonedAfter, + abandonedResumeAllowed: queue.abandonedResumeAllowed, + skipAgentsWithExternalCalls: queue.skipAgentsWithExternalCalls, + recordingEnabled: queue.recordingEnabled, + }); + files.push({ name: `${queue.id}.xml`, xml }); + desired.push({ file: `${queue.id}.xml`, queueName }); + } + } + + await mkdir(QUEUES_DIR, { recursive: true }); + const existing = await readdir(QUEUES_DIR).catch(() => [] as string[]); + const wantedFiles = new Set(files.map((f) => f.name)); + const staleFiles = existing.filter((f) => !wantedFiles.has(f)); + + await Promise.all(files.map((f) => writeFile(join(QUEUES_DIR, f.name), f.xml, "utf8"))); + await Promise.all(staleFiles.map((f) => rm(join(QUEUES_DIR, f)))); + + logger.info("filas sincronizadas", { desired: desired.length, removed: staleFiles.length }); + + try { + const providerInstance = getProvider(); + const connected = await providerInstance.waitUntilConnected(5000); + if (!connected) { + logger.warn("ESL ainda nao conectado, reload de filas sera tentado na proxima sincronizacao"); + return; + } + + await providerInstance.runApi("reloadxml"); + + for (const { queueName } of desired) { + await providerInstance.runApi(`callcenter_config queue reload ${queueName}`); + } + + // Descarrega filas cujo arquivo foi removido nesta sincronizacao. O + // registro no banco já não existe mais nesse ponto, então não dá pra + // saber o domain exato do tenant dono — como hoje o domain é + // compartilhado entre tenants (limitação conhecida, ver + // docs/EXTENSIONS.md), usamos o de qualquer tenant ativo como + // aproximação razoável. + const anyDomain = tenants.find((t) => t.telephonyDomain)?.telephonyDomain; + if (anyDomain) { + for (const staleFile of staleFiles) { + const queueId = staleFile.replace(/\.xml$/, ""); + await providerInstance + .runApi(`callcenter_config queue unload ${queueId}@${anyDomain}`) + .catch(() => undefined); + } + } + + logger.info("callcenter reloadxml + queue reload executados", { count: desired.length }); + } catch (err) { + logger.error("falha ao recarregar filas no FreeSWITCH", { error: String(err) }); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index ffed3e0..2d9e4f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,11 +53,16 @@ services: ESL_PORT: "8021" ESL_PASSWORD: ${ESL_PASSWORD} SOFIA_EXTERNAL_GATEWAYS_DIR: /gateways + CALLCENTER_QUEUES_DIR: /callcenter-queues volumes: # Compartilhado com o FreeSWITCH (agente.md secao 41-42): fs-config # escreve os XML de gateway aqui, o profile "external" ja inclui # sip_profiles/external/*.xml automaticamente (config vanilla). - freeswitch_external_gateways:/gateways + # Compartilhado com o FreeSWITCH (agente.md secao 50-51): fs-config + # escreve um XML de fila por arquivo aqui, incluido via + # X-PRE-PROCESS no nosso callcenter.conf.xml (mesmo padrao acima). + - freeswitch_callcenter_queues:/callcenter-queues # Sem porta publicada: so o FreeSWITCH (mesma rede do compose) chama isto. healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] @@ -81,6 +86,7 @@ services: FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD} volumes: - freeswitch_external_gateways:/etc/freeswitch/sip_profiles/external + - freeswitch_callcenter_queues:/etc/freeswitch/autoload_configs/callcenter_queues.conf.d # Nenhuma porta publicada no host: SIP/RTP ainda não têm troncos reais # configurados, e o Event Socket (8021) só deve ser alcançável por outros # containers na rede interna do compose (agente.md secao 22). @@ -119,3 +125,4 @@ volumes: postgres_data: redis_data: freeswitch_external_gateways: + freeswitch_callcenter_queues: diff --git a/docs/QUEUES.md b/docs/QUEUES.md new file mode 100644 index 0000000..7ceeb8b --- /dev/null +++ b/docs/QUEUES.md @@ -0,0 +1,82 @@ +# Filas (mod_callcenter) + +Agente.md secao 37 (mod_callcenter como ACD) e 50-51 (Filas/Estratégias). +Primeira peça do Call Center — Agentes/Tiers/Pausas ficam pra próxima fase +(dependem do fluxo de login do agente, seção 45-49, escopo maior). + +## Descoberta real sobre `callcenter_config` + +Antes de escrever qualquer código, rodei `help callcenter_config` no +FreeSWITCH real pra ver a sintaxe exata — e ela é bem diferente do que os +Trunks fizeram supor: + +- **Filas**: só `queue load`/`unload`/`reload`/`list` — **não existe** + `queue add` nem `queue set param`. Filas só podem vir de XML estático, + carregado/recarregado por nome. +- **Agentes**: `agent add`/`del`/`set status`/`set state`/... — 100% + dinâmico via comando ESL, sem XML. +- **Tiers**: `tier add`/`del`/`set state`/`set level`/`set position` — também + 100% dinâmico. + +Ou seja, filas usam o mesmo padrão "arquivo + reload" dos Trunks; agentes e +tiers (próxima fase) vão usar comandos ESL diretos, sem arquivo nenhum. + +## Mecanismo + +Mesmo padrão dos gateways Sofia: um arquivo XML por fila +(`autoload_configs/callcenter_queues.conf.d/.xml`, volume Docker +compartilhado), incluído via `X-PRE-PROCESS` no nosso +`callcenter.conf.xml` próprio (agente.md secao 15 — "carregar somente o +necessário": zeramos os ``/`` estáticos da vanilla, já que +essas partes vão ser 100% dinâmicas). + +**Sequência de comandos confirmada manualmente contra o FreeSWITCH real** +(testei cada passo antes de escrever o código): + +1. `queue load ` **falha** ("Invalid Queue not found!") se o arquivo + foi adicionado depois do boot — a árvore XML em memória não sabe do + arquivo novo ainda. +2. `reloadxml` primeiro repopula essa árvore a partir do disco. +3. Depois disso, **`queue reload ` sozinho** já serve tanto pra criar + quanto atualizar — não precisa distinguir `load` de `reload`. +4. Fila removida: apagar o arquivo, `reloadxml`, `queue unload `. + +`b2bcall-fs-config` (`queue-sync.ts`) reescreve todos os arquivos de fila +habilitada (todos os tenants) a cada sync, remove os obsoletos, roda +`reloadxml` uma vez, depois `queue reload` por fila desejada e +`queue unload` por fila removida. Disparado por Redis pub/sub +(`b2bcall:queues:sync`, mesmo mecanismo dos Trunks) a cada create/delete via +API, e uma vez no boot do serviço. + +## Nome da fila no FreeSWITCH + +`@` — mesma convenção UUID dos gateways. +Como o `telephonyDomain` hoje é compartilhado entre tenants (limitação já +documentada em docs/EXTENSIONS.md), o unload de uma fila apagada usa o +domain de "qualquer tenant ativo" como aproximação, já que o registro no +banco já não existe mais nesse ponto pra sabermos o domain exato. + +## Verificado ponta a ponta + +``` +POST /queues {"name":"Suporte","strategy":"ROUND_ROBIN","maxWaitTime":120,"discardAbandonedAfter":90} +→ fs-config sincroniza (desired:1) → reloadxml + queue reload + +callcenter_config queue list +→ @b2bcall.local|round-robin|...|90|false|120|... (parâmetros batem) + +DELETE /queues/:id +→ fs-config sincroniza (removed:1) → reloadxml + queue unload +→ callcenter_config queue list volta vazia +``` + +## O que falta + +- Agentes, Tiers, Pausas (secao 45-49) — fase separada, mecanismo é + puramente via comando ESL (`agent add`, `tier add`), sem arquivo. +- Monitoramento em tempo real das filas (secao 54) — depende de WebSocket + multi-tenant, que ainda não existe. +- `tier-rule-wait-multiply-level` e `tier-rule-no-agent-no-wait` (vistos no + `queue list` da config vanilla) não são expostos como campos próprios + ainda — ficaram de fora do escopo desta fase. +- Quota de filas (`max_queues`) — depende de Plans/Entitlements. diff --git a/infrastructure/freeswitch/Dockerfile b/infrastructure/freeswitch/Dockerfile index e02d9b3..2e4e5cd 100644 --- a/infrastructure/freeswitch/Dockerfile +++ b/infrastructure/freeswitch/Dockerfile @@ -46,6 +46,8 @@ COPY overrides/autoload_configs/modules.conf.xml /etc/freeswitch/autoload_config COPY overrides/autoload_configs/event_socket.conf.xml /etc/freeswitch/autoload_configs/event_socket.conf.xml COPY overrides/autoload_configs/acl.conf.xml /etc/freeswitch/autoload_configs/acl.conf.xml COPY overrides/autoload_configs/xml_curl.conf.xml /etc/freeswitch/autoload_configs/xml_curl.conf.xml +COPY overrides/autoload_configs/callcenter.conf.xml /etc/freeswitch/autoload_configs/callcenter.conf.xml +RUN mkdir -p /etc/freeswitch/autoload_configs/callcenter_queues.conf.d # Pino $${domain} num valor estavel em vez do IP dinamico do container # (vars.xml vanilla usa "domain=$${local_ip_v4}", que muda a cada restart e diff --git a/infrastructure/freeswitch/overrides/autoload_configs/callcenter.conf.xml b/infrastructure/freeswitch/overrides/autoload_configs/callcenter.conf.xml new file mode 100644 index 0000000..97e2956 --- /dev/null +++ b/infrastructure/freeswitch/overrides/autoload_configs/callcenter.conf.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/packages/database/prisma/migrations/20260828120508_queues/migration.sql b/packages/database/prisma/migrations/20260828120508_queues/migration.sql new file mode 100644 index 0000000..4d745da --- /dev/null +++ b/packages/database/prisma/migrations/20260828120508_queues/migration.sql @@ -0,0 +1,44 @@ +-- CreateEnum +CREATE TYPE "queue_strategy" AS ENUM ('LONGEST_IDLE_AGENT', 'ROUND_ROBIN', 'TOP_DOWN', 'AGENT_WITH_LEAST_TALK_TIME', 'AGENT_WITH_FEWEST_CALLS', 'SEQUENTIALLY_BY_AGENT_ORDER', 'RING_ALL', 'RING_PROGRESSIVELY'); + +-- CreateTable +CREATE TABLE "queues" ( + "id" UUID NOT NULL, + "tenant_id" UUID NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "strategy" "queue_strategy" NOT NULL DEFAULT 'LONGEST_IDLE_AGENT', + "moh_sound" TEXT, + "announce_sound" TEXT, + "announce_frequency" INTEGER NOT NULL DEFAULT 0, + "max_wait_time" INTEGER NOT NULL DEFAULT 0, + "max_wait_time_with_no_agent" INTEGER NOT NULL DEFAULT 0, + "agent_no_answer_status" TEXT, + "tier_rules_apply" BOOLEAN NOT NULL DEFAULT false, + "tier_rule_wait_second" INTEGER NOT NULL DEFAULT 300, + "discard_abandoned_after" INTEGER NOT NULL DEFAULT 60, + "abandoned_resume_allowed" BOOLEAN NOT NULL DEFAULT false, + "skip_agents_with_external_calls" BOOLEAN NOT NULL DEFAULT true, + "recording_enabled" BOOLEAN NOT NULL DEFAULT false, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "queues_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "queues_tenant_id_idx" ON "queues"("tenant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "queues_tenant_id_name_key" ON "queues"("tenant_id", "name"); + +-- AddForeignKey +ALTER TABLE "queues" ADD CONSTRAINT "queues_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Tabela de negocio tenant-scoped: RLS obrigatorio (ver docs/TENANT_ISOLATION.md). +ALTER TABLE "queues" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "queues" FORCE ROW LEVEL SECURITY; +CREATE POLICY "tenant_isolation" ON "queues" + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 511168f..f6f0498 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -38,6 +38,7 @@ model Tenant { trunks Trunk[] dialplanExtensions DialplanExtension[] dialplanVersions DialplanVersion[] + queues Queue[] @@map("tenants") } @@ -379,3 +380,61 @@ model DialplanVersion { @@index([tenantId, context, status]) @@map("dialplan_versions") } + +enum QueueStrategy { + LONGEST_IDLE_AGENT + ROUND_ROBIN + TOP_DOWN + AGENT_WITH_LEAST_TALK_TIME + AGENT_WITH_FEWEST_CALLS + SEQUENTIALLY_BY_AGENT_ORDER + RING_ALL + RING_PROGRESSIVELY + + @@map("queue_strategy") +} + +// Tabela tenant-scoped protegida por RLS (agente.md secao 50-51). O nome no +// FreeSWITCH é `@` — mod_callcenter usa +// um namespace unico compartilhado entre tenants (nao ha equivalente ao +// diretorio por-arquivo do Sofia pra isolar por tenant, ver docs/QUEUES.md). +model Queue { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + + name String + description String? + + strategy QueueStrategy @default(LONGEST_IDLE_AGENT) + + mohSound String? @map("moh_sound") + announceSound String? @map("announce_sound") + announceFrequency Int @default(0) @map("announce_frequency") + + maxWaitTime Int @default(0) @map("max_wait_time") + maxWaitTimeWithNoAgent Int @default(0) @map("max_wait_time_with_no_agent") + + agentNoAnswerStatus String? @map("agent_no_answer_status") + + tierRulesApply Boolean @default(false) @map("tier_rules_apply") + tierRuleWaitSecond Int @default(300) @map("tier_rule_wait_second") + + discardAbandonedAfter Int @default(60) @map("discard_abandoned_after") + abandonedResumeAllowed Boolean @default(false) @map("abandoned_resume_allowed") + + skipAgentsWithExternalCalls Boolean @default(true) @map("skip_agents_with_external_calls") + + recordingEnabled Boolean @default(false) @map("recording_enabled") + + enabled Boolean @default(true) + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + tenant Tenant @relation(fields: [tenantId], references: [id]) + + @@unique([tenantId, name]) + @@index([tenantId]) + @@map("queues") +} diff --git a/packages/telephony/src/index.ts b/packages/telephony/src/index.ts index cc786d6..057daa7 100644 --- a/packages/telephony/src/index.ts +++ b/packages/telephony/src/index.ts @@ -4,3 +4,4 @@ export * from "./freeswitch-provider"; export * from "./directory-xml"; export * from "./gateway-xml"; export * from "./dialplan-xml"; +export * from "./queue-xml"; diff --git a/packages/telephony/src/queue-xml.ts b/packages/telephony/src/queue-xml.ts new file mode 100644 index 0000000..22acefa --- /dev/null +++ b/packages/telephony/src/queue-xml.ts @@ -0,0 +1,78 @@ +function xmlEscape(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +const STRATEGY_VALUES: Record = { + LONGEST_IDLE_AGENT: "longest-idle-agent", + ROUND_ROBIN: "round-robin", + TOP_DOWN: "top-down", + AGENT_WITH_LEAST_TALK_TIME: "agent-with-least-talk-time", + AGENT_WITH_FEWEST_CALLS: "agent-with-fewest-calls", + SEQUENTIALLY_BY_AGENT_ORDER: "sequentially-by-agent-order", + RING_ALL: "ring-all", + RING_PROGRESSIVELY: "ring-progressively", +}; + +export interface QueueXmlParams { + queueName: string; // ja no formato "@" + strategy: keyof typeof STRATEGY_VALUES; + mohSound?: string; + announceSound?: string; + announceFrequency: number; + maxWaitTime: number; + maxWaitTimeWithNoAgent: number; + agentNoAnswerStatus?: string; + tierRulesApply: boolean; + tierRuleWaitSecond: number; + discardAbandonedAfter: number; + abandonedResumeAllowed: boolean; + skipAgentsWithExternalCalls: boolean; + recordingEnabled: boolean; +} + +/** + * XML de fila do mod_callcenter (agente.md secao 50-51). Escrito em + * autoload_configs/callcenter_queues.conf.d/.xml (volume Docker + * compartilhado, incluído via X-PRE-PROCESS no callcenter.conf.xml — + * mesmo padrão dos gateways Sofia). Carregado com `callcenter_config queue + * reload ` via ESL, não é resolvido dinamicamente por chamada como o + * dialplan. + */ +export function buildQueueXml(params: QueueXmlParams): string { + const lines: string[] = []; + const param = (name: string, value: string | number | boolean | undefined) => { + if (value === undefined || value === "") return; + lines.push(` `); + }; + + param("strategy", STRATEGY_VALUES[params.strategy]); + param("moh-sound", params.mohSound); + param("announce-sound", params.announceSound); + param("announce-frequency", params.announceFrequency); + param("time-base-score", "system"); + param("max-wait-time", params.maxWaitTime); + param("max-wait-time-with-no-agent", params.maxWaitTimeWithNoAgent); + param("agent-no-answer-status", params.agentNoAnswerStatus); + param("tier-rules-apply", params.tierRulesApply); + param("tier-rule-wait-second", params.tierRuleWaitSecond); + param("discard-abandoned-after", params.discardAbandonedAfter); + param("abandoned-resume-allowed", params.abandonedResumeAllowed); + param("skip-agents-with-external-calls", params.skipAgentsWithExternalCalls); + if (params.recordingEnabled) { + param( + "record-template", + "$${recordings_dir}/${strftime(%Y-%m-%d-%H-%M-%S)}.${destination_number}.${caller_id_number}.${uuid}.wav", + ); + } + + return ` + +${lines.join("\n")} + +`; +}