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
211 lines
7.7 KiB
TypeScript
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);
|
|
});
|