feat: implement Call Center queues (mod_callcenter)
- Investigated the real callcenter_config command surface via
'help callcenter_config' on the running FreeSWITCH before writing any
code: queues only have load/unload/reload (static XML + reload, no
'queue add' exists), while agents and tiers are fully dynamic via ESL
commands (agent add, tier add) -- no file involved. This shapes the next
phase (Agents/Tiers) differently from this one.
- queues table (tenant-scoped, RLS): strategy, moh/announce, wait times,
tier rules, discard/abandoned handling, skip-agents-with-external-calls,
recording_enabled
- packages/telephony: buildQueueXml()
- infrastructure/freeswitch: our own callcenter.conf.xml override (empties
the vanilla static agents/tiers -- those become fully dynamic in the next
phase) that includes callcenter_queues.conf.d/*.xml via X-PRE-PROCESS,
same pattern as the Sofia gateway directory
- apps/api/src/queues: CRUD (POST/GET/GET:id/DELETE) using the queues.view/
.manage permissions already in the seed
- b2bcall-fs-config (queue-sync.ts): one XML file per queue on a shared
volume, synced via Redis pub/sub (b2bcall:queues:sync) on create/delete
and once at boot -- same shape as trunk-sync.ts
- confirmed manually against the real FreeSWITCH, before coding the sync
logic: 'queue load <name>' fails ('Invalid Queue not found!') for a file
added after boot -- needs 'reloadxml' first to repopulate the in-memory
XML tree from disk; after that, 'queue reload <name>' alone handles both
create and update, no need to distinguish load vs reload
- verified end-to-end: created a queue (ROUND_ROBIN, maxWaitTime=120,
discardAbandonedAfter=90), 'callcenter_config queue list' showed the
correct values on the FreeSWITCH side; deleted it, list went back empty
docs/QUEUES.md
This commit is contained in:
29
TODO.md
29
TODO.md
@@ -173,7 +173,34 @@
|
|||||||
múltiplas em sequência, não implementado
|
múltiplas em sequência, não implementado
|
||||||
- [ ] `dialplan.view`/`.manage` não existem — reusei `freeswitch.*`
|
- [ ] `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)
|
Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { AuthModule } from "./auth/auth.module";
|
|||||||
import { ExtensionsModule } from "./extensions/extensions.module";
|
import { ExtensionsModule } from "./extensions/extensions.module";
|
||||||
import { TrunksModule } from "./trunks/trunks.module";
|
import { TrunksModule } from "./trunks/trunks.module";
|
||||||
import { DialplanModule } from "./dialplan/dialplan.module";
|
import { DialplanModule } from "./dialplan/dialplan.module";
|
||||||
|
import { QueuesModule } from "./queues/queues.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HealthModule, AuthModule, ExtensionsModule, TrunksModule, DialplanModule],
|
imports: [HealthModule, AuthModule, ExtensionsModule, TrunksModule, DialplanModule, QueuesModule],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
88
apps/api/src/queues/dto/create-queue.dto.ts
Normal file
88
apps/api/src/queues/dto/create-queue.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
125
apps/api/src/queues/queues.controller.ts
Normal file
125
apps/api/src/queues/queues.controller.ts
Normal file
@@ -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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/queues/queues.module.ts
Normal file
7
apps/api/src/queues/queues.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { QueuesController } from "./queues.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [QueuesController],
|
||||||
|
})
|
||||||
|
export class QueuesModule {}
|
||||||
@@ -7,10 +7,12 @@ import { decryptSecret } from "@b2bcall/shared";
|
|||||||
import { buildDirectoryUserXml, NOT_FOUND_XML } from "@b2bcall/telephony";
|
import { buildDirectoryUserXml, NOT_FOUND_XML } from "@b2bcall/telephony";
|
||||||
import { createLogger } from "@b2bcall/shared";
|
import { createLogger } from "@b2bcall/shared";
|
||||||
import { syncTrunks } from "./trunk-sync";
|
import { syncTrunks } from "./trunk-sync";
|
||||||
|
import { syncQueues } from "./queue-sync";
|
||||||
|
|
||||||
const logger = createLogger("b2bcall-fs-config");
|
const logger = createLogger("b2bcall-fs-config");
|
||||||
|
|
||||||
const TRUNKS_SYNC_CHANNEL = "b2bcall:trunks:sync";
|
const TRUNKS_SYNC_CHANNEL = "b2bcall:trunks:sync";
|
||||||
|
const QUEUES_SYNC_CHANNEL = "b2bcall:queues:sync";
|
||||||
|
|
||||||
function requireEnv(name: string): string {
|
function requireEnv(name: string): string {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
@@ -172,12 +174,17 @@ async function main() {
|
|||||||
// criados enquanto este servico estava fora do ar.
|
// criados enquanto este servico estava fora do ar.
|
||||||
const subscriber = new Redis(process.env.REDIS_URL!);
|
const subscriber = new Redis(process.env.REDIS_URL!);
|
||||||
subscriber.on("error", (err) => logger.error("erro na conexao Redis (subscriber)", { error: String(err) }));
|
subscriber.on("error", (err) => logger.error("erro na conexao Redis (subscriber)", { error: String(err) }));
|
||||||
await subscriber.subscribe(TRUNKS_SYNC_CHANNEL);
|
await subscriber.subscribe(TRUNKS_SYNC_CHANNEL, QUEUES_SYNC_CHANNEL);
|
||||||
subscriber.on("message", (_channel, _msg) => {
|
subscriber.on("message", (channel, _msg) => {
|
||||||
|
if (channel === TRUNKS_SYNC_CHANNEL) {
|
||||||
syncTrunks().catch((err) => logger.error("falha ao sincronizar trunks", { error: String(err) }));
|
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) }));
|
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) => {
|
main().catch((err) => {
|
||||||
|
|||||||
122
apps/freeswitch-config/src/queue-sync.ts
Normal file
122
apps/freeswitch-config/src/queue-sync.ts
Normal file
@@ -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 <nome>` sozinho
|
||||||
|
* já serve tanto pra criar quanto atualizar (não precisa distinguir
|
||||||
|
* load/reload). Ver docs/QUEUES.md.
|
||||||
|
*/
|
||||||
|
export async function syncQueues(): Promise<void> {
|
||||||
|
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) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,11 +53,16 @@ services:
|
|||||||
ESL_PORT: "8021"
|
ESL_PORT: "8021"
|
||||||
ESL_PASSWORD: ${ESL_PASSWORD}
|
ESL_PASSWORD: ${ESL_PASSWORD}
|
||||||
SOFIA_EXTERNAL_GATEWAYS_DIR: /gateways
|
SOFIA_EXTERNAL_GATEWAYS_DIR: /gateways
|
||||||
|
CALLCENTER_QUEUES_DIR: /callcenter-queues
|
||||||
volumes:
|
volumes:
|
||||||
# Compartilhado com o FreeSWITCH (agente.md secao 41-42): fs-config
|
# Compartilhado com o FreeSWITCH (agente.md secao 41-42): fs-config
|
||||||
# escreve os XML de gateway aqui, o profile "external" ja inclui
|
# escreve os XML de gateway aqui, o profile "external" ja inclui
|
||||||
# sip_profiles/external/*.xml automaticamente (config vanilla).
|
# sip_profiles/external/*.xml automaticamente (config vanilla).
|
||||||
- freeswitch_external_gateways:/gateways
|
- 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.
|
# Sem porta publicada: so o FreeSWITCH (mesma rede do compose) chama isto.
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
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}
|
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- freeswitch_external_gateways:/etc/freeswitch/sip_profiles/external
|
- 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
|
# 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
|
# configurados, e o Event Socket (8021) só deve ser alcançável por outros
|
||||||
# containers na rede interna do compose (agente.md secao 22).
|
# containers na rede interna do compose (agente.md secao 22).
|
||||||
@@ -119,3 +125,4 @@ volumes:
|
|||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
freeswitch_external_gateways:
|
freeswitch_external_gateways:
|
||||||
|
freeswitch_callcenter_queues:
|
||||||
|
|||||||
82
docs/QUEUES.md
Normal file
82
docs/QUEUES.md
Normal file
@@ -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/<queue_id>.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 `<agents>`/`<tiers>` 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 <nome>` **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 <nome>` sozinho** já serve tanto pra criar
|
||||||
|
quanto atualizar — não precisa distinguir `load` de `reload`.
|
||||||
|
4. Fila removida: apagar o arquivo, `reloadxml`, `queue unload <nome>`.
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
||||||
|
`<queue.id>@<tenant.telephonyDomain>` — 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
|
||||||
|
→ <id>@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.
|
||||||
@@ -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/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/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/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
|
# 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
|
# (vars.xml vanilla usa "domain=$${local_ip_v4}", que muda a cada restart e
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<configuration name="callcenter.conf" description="CallCenter">
|
||||||
|
<settings>
|
||||||
|
</settings>
|
||||||
|
|
||||||
|
<queues>
|
||||||
|
<!-- Filas geradas pelo b2bcall-fs-config (uma por arquivo, mesmo padrao
|
||||||
|
dos gateways Sofia em sip_profiles/external/*.xml). Nunca editar
|
||||||
|
manualmente — agente.md secao 50-51. -->
|
||||||
|
<X-PRE-PROCESS cmd="include" data="callcenter_queues.conf.d/*.xml"/>
|
||||||
|
</queues>
|
||||||
|
|
||||||
|
<!-- Agentes e tiers sao 100% dinamicos via `callcenter_config agent/tier
|
||||||
|
add` (ESL) — nao ficam em XML (fase Agents/Tiers, ainda nao
|
||||||
|
implementada). -->
|
||||||
|
<agents>
|
||||||
|
</agents>
|
||||||
|
<tiers>
|
||||||
|
</tiers>
|
||||||
|
</configuration>
|
||||||
@@ -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);
|
||||||
@@ -38,6 +38,7 @@ model Tenant {
|
|||||||
trunks Trunk[]
|
trunks Trunk[]
|
||||||
dialplanExtensions DialplanExtension[]
|
dialplanExtensions DialplanExtension[]
|
||||||
dialplanVersions DialplanVersion[]
|
dialplanVersions DialplanVersion[]
|
||||||
|
queues Queue[]
|
||||||
|
|
||||||
@@map("tenants")
|
@@map("tenants")
|
||||||
}
|
}
|
||||||
@@ -379,3 +380,61 @@ model DialplanVersion {
|
|||||||
@@index([tenantId, context, status])
|
@@index([tenantId, context, status])
|
||||||
@@map("dialplan_versions")
|
@@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 é `<queue.id>@<tenant.telephonyDomain>` — 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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from "./freeswitch-provider";
|
|||||||
export * from "./directory-xml";
|
export * from "./directory-xml";
|
||||||
export * from "./gateway-xml";
|
export * from "./gateway-xml";
|
||||||
export * from "./dialplan-xml";
|
export * from "./dialplan-xml";
|
||||||
|
export * from "./queue-xml";
|
||||||
|
|||||||
78
packages/telephony/src/queue-xml.ts
Normal file
78
packages/telephony/src/queue-xml.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
function xmlEscape(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRATEGY_VALUES: Record<string, string> = {
|
||||||
|
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 "<id>@<domain>"
|
||||||
|
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/<queue_id>.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 <nome>` 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 name="${name}" value="${xmlEscape(String(value))}"/>`);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 `<include>
|
||||||
|
<queue name="${xmlEscape(params.queueName)}">
|
||||||
|
${lines.join("\n")}
|
||||||
|
</queue>
|
||||||
|
</include>`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user