Files
B2BCall-dialer/apps/freeswitch-config/src/main.ts
Matheus a05104a05f 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
2026-08-28 09:58:45 -03:00

211 lines
7.7 KiB
TypeScript

import { timingSafeEqual } from "node:crypto";
import Fastify from "fastify";
import formbody from "@fastify/formbody";
import Redis from "ioredis";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
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";
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 {
const value = process.env[name];
if (!value) {
throw new Error(`${name} nao definido no ambiente`);
}
return value;
}
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
interface XmlCurlBody {
section?: string;
purpose?: string;
user?: string;
domain?: string;
context?: string;
"Caller-Context"?: string;
hostname?: string;
[key: string]: unknown;
}
async function resolveDirectoryXml(user: string | undefined, domain: string | undefined): Promise<string> {
if (!user || !domain) {
return NOT_FOUND_XML;
}
const prisma = getPrismaClient();
// tenants nao e' tenant-scoped (e' o proprio registro de tenants — sem
// RLS, ver docs/TENANT_ISOLATION.md), mas so devolvemos dados de UM
// tenant especifico depois de achar o dono do dominio.
const tenant = await prisma.tenant.findFirst({ where: { telephonyDomain: domain, status: "ACTIVE" } });
if (!tenant) {
return NOT_FOUND_XML;
}
const extension = await withTenantContext(prisma, tenant.id, (tx) =>
tx.extension.findFirst({ where: { tenantId: tenant.id, number: user, enabled: true, deletedAt: null } }),
);
if (!extension) {
return NOT_FOUND_XML;
}
return buildDirectoryUserXml({
domain: extension.domain,
extensionNumber: extension.number,
extensionName: extension.name,
sipPassword: decryptSecret(extension.sipPasswordEnc),
context: extension.context,
callerIdName: extension.callerIdName ?? undefined,
callerIdNumber: extension.callerIdNumber ?? undefined,
tenantId: extension.tenantId,
extensionId: extension.id,
});
}
/**
* Resolve tenant pelo channel variable `b2bcall_tenant_id` — injetado em
* toda chamada originada de um ramal nosso (ver buildDirectoryUserXml).
* Ao contrário do directory (resolvido por domain, que hoje é o mesmo pra
* todos os tenants — limitação conhecida, ver docs/EXTENSIONS.md), o
* dialplan já tem essa variável disponível na própria chamada, então nem
* sofre da mesma ambiguidade.
*
* Serve o XML JÁ GERADO da versão ACTIVE (dialplan_versions.generated_xml)
* — nunca reconstrói ao vivo a partir de dialplan_extensions. Editar as
* linhas do editor estruturado não afeta chamadas até uma nova versão ser
* gerada e ativada (agente.md secao 44).
*/
async function resolveDialplanXml(body: XmlCurlBody): Promise<string> {
const tenantId = body["variable_b2bcall_tenant_id"] as string | undefined;
const context = (body["Caller-Context"] ?? body.context ?? "default") as string;
if (!tenantId) {
return NOT_FOUND_XML;
}
const prisma = getPrismaClient();
const version = await withTenantContext(prisma, tenantId, (tx) =>
tx.dialplanVersion.findFirst({ where: { tenantId, context, status: "ACTIVE" } }),
);
if (!version) {
return NOT_FOUND_XML;
}
return version.generatedXml;
}
async function main() {
const expectedUser = requireEnv("FS_CONFIG_USER");
const expectedPassword = requireEnv("FS_CONFIG_PASSWORD");
const app = Fastify({ logger: false });
await app.register(formbody);
// Agora que este servico devolve dados reais (senha SIP decifrada), o
// FreeSWITCH precisa se autenticar — configurado via `gateway-credentials`
// em xml_curl.conf.xml (agente.md secao 26 + docs/EXTENSIONS.md).
app.addHook("preHandler", async (request, reply) => {
if (request.url === "/health") return;
const header = request.headers.authorization;
if (!header?.startsWith("Basic ")) {
reply.code(401).header("WWW-Authenticate", "Basic").send();
return reply;
}
const [user, password] = Buffer.from(header.slice("Basic ".length), "base64")
.toString("utf8")
.split(":");
if (!user || !password || !safeEqual(user, expectedUser) || !safeEqual(password, expectedPassword)) {
logger.warn("tentativa de acesso com credenciais invalidas");
reply.code(401).header("WWW-Authenticate", "Basic").send();
return reply;
}
});
app.post<{ Body: XmlCurlBody }>("/", async (request, reply) => {
const { section, purpose, user, domain } = request.body ?? {};
logger.info("requisicao xml_curl recebida", { section, purpose, user, domain });
reply.header("Content-Type", "text/xml");
if (section === "directory") {
try {
return await resolveDirectoryXml(user, domain);
} catch (err) {
logger.error("erro resolvendo directory", { error: String(err) });
return NOT_FOUND_XML;
}
}
if (section === "dialplan") {
try {
return await resolveDialplanXml(request.body ?? {});
} catch (err) {
logger.error("erro resolvendo dialplan", { error: String(err) });
return NOT_FOUND_XML;
}
}
return NOT_FOUND_XML;
});
app.get("/health", async () => ({ status: "ok" }));
const port = Number(process.env.PORT ?? 8080);
await app.listen({ port, host: "0.0.0.0" });
logger.info(`b2bcall-fs-config ouvindo na porta ${port}`);
// apps/api publica no canal apos criar/editar/apagar um trunk (agente.md
// secao 41-42). Roda uma sincronizacao inicial tambem, pra cobrir trunks
// 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, 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) });
}
}
});
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) => {
logger.error("falha fatal ao iniciar b2bcall-fs-config", { error: String(err) });
process.exit(1);
});