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:
2026-08-28 09:15:11 -03:00
parent 0720a0efe3
commit 6628578c42
15 changed files with 674 additions and 5 deletions

View File

@@ -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 {}

View 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;
}

View 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();
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { QueuesController } from "./queues.controller";
@Module({
controllers: [QueuesController],
})
export class QueuesModule {}

View File

@@ -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) => {

View 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) });
}
}