feat(agents): agentes, tiers e pausas — call center completo
Fecha agente.md secao 45-49/52. Depois desta fase, um usuario autenticado consegue logar como agente, entrar numa fila real, se pausar e voltar, tudo refletido de verdade no FreeSWITCH. Schema (migration 20260828124245_agents): - agents (tenant-scoped, RLS): User -> Extension -> identidade de agente, state (enum AgentState de 8 valores) espelhando o estado real, so alterado via login/logout/pause/resume, nunca escrito direto pela API. - tiers: Queue<->Agent (level/position 1:1 com mod_callcenter). - agent_sessions: um ciclo login->logout por linha. - agent_state_events: historico de transicoes de estado. - pause_reasons / agent_pause_events (secao 48). Dois bugs reais corrigidos em FreeSwitchTelephonyProvider, presentes desde a fase de Event Socket original: - "queue add/del member" nao existe no mod_callcenter — membership de fila usa tier add/tier del. So foi pego agora ao confirmar de novo a sintaxe via `help callcenter_config` antes de codar esta fase. - addAgent/removeAgent nao existiam ainda (agent add/del). Mecanismo de sync: agentes e tiers nao tem representacao em XML, so comando ESL direto — diferente do padrao "regenera todos os arquivos" usado em Trunks/Queues. apps/api publica uma mensagem por acao com payload (b2bcall:agents:sync, b2bcall:tiers:sync); b2bcall-fs-config aplica o comando correspondente (agent-sync.ts). Achados confirmados manualmente contra o FreeSWITCH real antes de codar: - `agent add`/`tier add` nao sao idempotentes (erro em duplicata) — sync ignora esse erro (.catch), condicao esperada em resync. - `agent del`/`tier del` em algo inexistente nao da erro — seguro chamar sem checar existencia antes. - `agent set status` so aceita 3 valores exatos (Available/On Break/ Logged Out) — testado deliberadamente com valor invalido. - Corrida real: atribuir tier antes do primeiro login do agente falha silenciosamente do lado do FreeSWITCH (agente so existe la a partir do `agent add` no login). Login sempre re-sincroniza todos os tiers do agente depois de garantir que ele existe — auto-correcao confirmada no teste ponta a ponta. apps/api: AgentsController (CRUD), AgentsMeController (login/logout/ pause/resume — sempre resolve o agente via JWT, nunca um agentId arbitrario do client), PauseReasonsController (CRUD), QueueAgentsController (POST/DELETE de tier em /queues/:id/agents). Verificado ponta a ponta via curl + fs_cli contra o FreeSWITCH real: login -> Available, pause -> On Break, resume -> Available, logout -> Logged Out, todos batendo entre Agent.state (banco) e `agent list` (FreeSWITCH). typecheck do workspace inteiro limpo. ~350MB de memoria total (docker stats). Documentado em docs/AGENTS.md, incluindo lacuna conhecida: estados derivados de chamada (RINGING/IN_CALL/WRAP_UP/RESERVED) dependem do evento CUSTOM callcenter::info, ainda nao comprovado chegando em fs-events nesta sessao (mesma lacuna de sofia::gateway_state ja documentada em docs/TRUNKS.md) — precisa de uma chamada real passando pela fila pra investigar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
This commit is contained in:
135
apps/freeswitch-config/src/agent-sync.ts
Normal file
135
apps/freeswitch-config/src/agent-sync.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { getPrismaClient, withTenantContext, type AgentState } from "@b2bcall/database";
|
||||
import { FreeSwitchTelephonyProvider } from "@b2bcall/telephony";
|
||||
import { createLogger } from "@b2bcall/shared";
|
||||
|
||||
const logger = createLogger("b2bcall-fs-config");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Mapeamento pro par status/state real do mod_callcenter (confirmado
|
||||
// manualmente: só "Available"/"On Break"/"Logged Out" são aceitos —
|
||||
// qualquer outro valor dá "-ERR Invalid Agent Status!"). Os estados
|
||||
// derivados de chamada (RINGING/IN_CALL/WRAP_UP/RESERVED) não têm status
|
||||
// próprio no mod_callcenter — ficam em "Available" com o `state` (não
|
||||
// `status`) mudando sozinho conforme a chamada progride; não escrevemos
|
||||
// esses na FreeSWITCH, só refletimos o que ela reportar (ver docs/AGENTS.md).
|
||||
const STATUS_MAP: Partial<Record<AgentState, string>> = {
|
||||
AVAILABLE: "Available",
|
||||
LOGGED_IN: "Available",
|
||||
RESERVED: "Available",
|
||||
RINGING: "Available",
|
||||
IN_CALL: "Available",
|
||||
WRAP_UP: "Available",
|
||||
PAUSED: "On Break",
|
||||
OFFLINE: "Logged Out",
|
||||
};
|
||||
|
||||
export interface AgentSyncMessage {
|
||||
tenantId: string;
|
||||
agentId: string;
|
||||
action: "upsert" | "delete";
|
||||
}
|
||||
|
||||
export interface TierSyncMessage {
|
||||
tenantId: string;
|
||||
queueId: string;
|
||||
agentId: string;
|
||||
level?: number;
|
||||
position?: number;
|
||||
action: "upsert" | "delete";
|
||||
}
|
||||
|
||||
/**
|
||||
* Agentes são 100% dinâmicos no mod_callcenter (sem arquivo, ao contrário
|
||||
* de filas/troncos) — `agent add` só roda uma vez (erro "already exist" se
|
||||
* repetido, capturado e ignorado); status/contact podem ser reenviados sem
|
||||
* problema (agente.md secao 45-47).
|
||||
*/
|
||||
export async function syncAgent(msg: AgentSyncMessage): Promise<void> {
|
||||
const providerInstance = getProvider();
|
||||
const connected = await providerInstance.waitUntilConnected(5000);
|
||||
if (!connected) {
|
||||
logger.warn("ESL nao conectado, sync de agente adiado", { agentId: msg.agentId });
|
||||
return;
|
||||
}
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const tenant = await prisma.tenant.findUnique({ where: { id: msg.tenantId } });
|
||||
if (!tenant?.telephonyDomain) return;
|
||||
|
||||
const agentName = `${msg.agentId}@${tenant.telephonyDomain}`;
|
||||
|
||||
if (msg.action === "delete") {
|
||||
await providerInstance.removeAgent(agentName).catch(() => undefined);
|
||||
logger.info("agente removido do FreeSWITCH", { agentName });
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = await withTenantContext(prisma, msg.tenantId, (tx) =>
|
||||
tx.agent.findFirst({
|
||||
where: { id: msg.agentId, tenantId: msg.tenantId, deletedAt: null },
|
||||
include: { extension: true },
|
||||
}),
|
||||
);
|
||||
if (!agent || !agent.enabled) {
|
||||
await providerInstance.removeAgent(agentName).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
await providerInstance.addAgent(agentName).catch(() => undefined); // "already exist" e' esperado/ok
|
||||
|
||||
if (agent.extension) {
|
||||
const contact = `{ignore_early_media=true}user/${agent.extension.number}@${agent.extension.domain}`;
|
||||
await providerInstance.setAgentContact(agentName, contact);
|
||||
}
|
||||
|
||||
const status = STATUS_MAP[agent.state] ?? "Logged Out";
|
||||
await providerInstance.setAgentStatus(agentName, status);
|
||||
|
||||
logger.info("agente sincronizado", { agentName, status });
|
||||
}
|
||||
|
||||
export async function syncTier(msg: TierSyncMessage): Promise<void> {
|
||||
const providerInstance = getProvider();
|
||||
const connected = await providerInstance.waitUntilConnected(5000);
|
||||
if (!connected) {
|
||||
logger.warn("ESL nao conectado, sync de tier adiado", { agentId: msg.agentId, queueId: msg.queueId });
|
||||
return;
|
||||
}
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const tenant = await prisma.tenant.findUnique({ where: { id: msg.tenantId } });
|
||||
if (!tenant?.telephonyDomain) return;
|
||||
|
||||
const agentName = `${msg.agentId}@${tenant.telephonyDomain}`;
|
||||
const queueName = `${msg.queueId}@${tenant.telephonyDomain}`;
|
||||
|
||||
if (msg.action === "delete") {
|
||||
await providerInstance.removeAgentFromQueue(queueName, agentName).catch(() => undefined);
|
||||
logger.info("tier removido", { queueName, agentName });
|
||||
return;
|
||||
}
|
||||
|
||||
await providerInstance
|
||||
.addAgentToQueue(queueName, agentName, msg.level ?? 1, msg.position ?? 1)
|
||||
.catch(() => undefined); // "already exist" e' esperado/ok
|
||||
|
||||
logger.info("tier sincronizado", { queueName, agentName });
|
||||
}
|
||||
@@ -8,10 +8,13 @@ import { buildDirectoryUserXml, NOT_FOUND_XML } from "@b2bcall/telephony";
|
||||
import { createLogger } from "@b2bcall/shared";
|
||||
import { syncTrunks } from "./trunk-sync";
|
||||
import { syncQueues } from "./queue-sync";
|
||||
import { syncAgent, syncTier, type AgentSyncMessage, type TierSyncMessage } from "./agent-sync";
|
||||
|
||||
const logger = createLogger("b2bcall-fs-config");
|
||||
|
||||
const TRUNKS_SYNC_CHANNEL = "b2bcall:trunks:sync";
|
||||
const AGENTS_SYNC_CHANNEL = "b2bcall:agents:sync";
|
||||
const TIERS_SYNC_CHANNEL = "b2bcall:tiers:sync";
|
||||
const QUEUES_SYNC_CHANNEL = "b2bcall:queues:sync";
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
@@ -174,12 +177,26 @@ 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, QUEUES_SYNC_CHANNEL);
|
||||
subscriber.on("message", (channel, _msg) => {
|
||||
await subscriber.subscribe(TRUNKS_SYNC_CHANNEL, QUEUES_SYNC_CHANNEL, AGENTS_SYNC_CHANNEL, TIERS_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) }));
|
||||
} else if (channel === AGENTS_SYNC_CHANNEL) {
|
||||
try {
|
||||
const parsed = JSON.parse(msg) as AgentSyncMessage;
|
||||
syncAgent(parsed).catch((err) => logger.error("falha ao sincronizar agente", { error: String(err) }));
|
||||
} catch (err) {
|
||||
logger.error("mensagem invalida no canal de agentes", { error: String(err) });
|
||||
}
|
||||
} else if (channel === TIERS_SYNC_CHANNEL) {
|
||||
try {
|
||||
const parsed = JSON.parse(msg) as TierSyncMessage;
|
||||
syncTier(parsed).catch((err) => logger.error("falha ao sincronizar tier", { error: String(err) }));
|
||||
} catch (err) {
|
||||
logger.error("mensagem invalida no canal de tiers", { error: String(err) });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user