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:
@@ -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) => {
|
||||
|
||||
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) });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user