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:
2026-08-28 09:58:45 -03:00
parent 6628578c42
commit a05104a05f
21 changed files with 1402 additions and 140 deletions

View File

@@ -0,0 +1,26 @@
import { getRedisClient } from "../common/redis";
const AGENTS_SYNC_CHANNEL = "b2bcall:agents:sync";
const TIERS_SYNC_CHANNEL = "b2bcall:tiers:sync";
export async function notifyAgentChanged(
tenantId: string,
agentId: string,
action: "upsert" | "delete" = "upsert",
): Promise<void> {
await getRedisClient().publish(AGENTS_SYNC_CHANNEL, JSON.stringify({ tenantId, agentId, action }));
}
export async function notifyTierChanged(
tenantId: string,
queueId: string,
agentId: string,
action: "upsert" | "delete",
level?: number,
position?: number,
): Promise<void> {
await getRedisClient().publish(
TIERS_SYNC_CHANNEL,
JSON.stringify({ tenantId, queueId, agentId, action, level, position }),
);
}

View File

@@ -0,0 +1,173 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
NotFoundException,
Post,
UseGuards,
} from "@nestjs/common";
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { PauseDto } from "./dto/pause.dto";
import { notifyAgentChanged, notifyTierChanged } from "./agent-sync.helper";
async function findMyAgent(tx: Prisma.TransactionClient, tenantId: string, userId: string) {
const agent = await tx.agent.findFirst({
where: { tenantId, userId, deletedAt: null },
include: { extension: true, tiers: true },
});
if (!agent) {
throw new NotFoundException("Nenhum agente vinculado a este usuario neste tenant");
}
return agent;
}
/**
* "Tela do Agente" (agente.md secao 49): DISPONÍVEL / PAUSA / FINALIZAR
* PAUSA / LOGOUT. Opera sempre sobre o agente do PRÓPRIO usuário
* autenticado — nunca aceita um agentId arbitrário do client (mesmo
* principio de nunca confiar em tenant_id do frontend, secao 31).
*/
@UseGuards(JwtAuthGuard)
@Controller("agents/me")
export class AgentsMeController {
/** Fluxo de login (agente.md secao 47): valida usuário (JWT) e ramal,
* cria sessão, configura contact/tiers no FreeSWITCH, fica AVAILABLE. */
@Post("login")
async login(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub));
if (!agent.extension) {
throw new BadRequestException("Agente sem ramal configurado — nao e' possivel logar");
}
if (!agent.enabled) {
throw new ForbiddenException("Agente desabilitado");
}
await withTenantContext(prisma, tenantId, async (tx) => {
await tx.agentSession.create({ data: { tenantId, agentId: agent.id } });
await tx.agentStateEvent.createMany({
data: [
{ tenantId, agentId: agent.id, state: "LOGGED_IN" },
{ tenantId, agentId: agent.id, state: "AVAILABLE" },
],
});
await tx.agent.update({
where: { id: agent.id },
data: { state: "AVAILABLE", stateUpdatedAt: new Date() },
});
});
await recordAuditEvent(prisma, { action: "AGENT_LOGIN", tenantId, userId: user.sub, entityType: "agent", entityId: agent.id });
await notifyAgentChanged(tenantId, agent.id, "upsert");
for (const tier of agent.tiers) {
await notifyTierChanged(tenantId, tier.queueId, agent.id, "upsert", tier.level, tier.position);
}
return { state: "AVAILABLE" };
}
@Post("logout")
async logout(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub));
await withTenantContext(prisma, tenantId, async (tx) => {
await tx.agentSession.updateMany({
where: { tenantId, agentId: agent.id, endedAt: null },
data: { endedAt: new Date() },
});
await tx.agentPauseEvent.updateMany({
where: { tenantId, agentId: agent.id, endedAt: null },
data: { endedAt: new Date() },
});
await tx.agentStateEvent.create({ data: { tenantId, agentId: agent.id, state: "OFFLINE" } });
await tx.agent.update({
where: { id: agent.id },
data: { state: "OFFLINE", stateUpdatedAt: new Date() },
});
});
await recordAuditEvent(prisma, { action: "AGENT_LOGOUT", tenantId, userId: user.sub, entityType: "agent", entityId: agent.id });
await notifyAgentChanged(tenantId, agent.id, "upsert");
return { state: "OFFLINE" };
}
@Post("pause")
async pause(@CurrentUser() user: AccessTokenClaims, @Body() dto: PauseDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub));
if (agent.state === "OFFLINE") {
throw new BadRequestException("Agente precisa estar logado pra entrar em pausa");
}
const pauseReason = await withTenantContext(prisma, tenantId, (tx) =>
tx.pauseReason.findFirst({ where: { id: dto.pauseReasonId, tenantId, enabled: true } }),
);
if (!pauseReason) {
throw new NotFoundException("Motivo de pausa nao encontrado");
}
await withTenantContext(prisma, tenantId, async (tx) => {
await tx.agentPauseEvent.create({
data: { tenantId, agentId: agent.id, pauseReasonId: pauseReason.id },
});
await tx.agentStateEvent.create({ data: { tenantId, agentId: agent.id, state: "PAUSED" } });
await tx.agent.update({
where: { id: agent.id },
data: { state: "PAUSED", stateUpdatedAt: new Date() },
});
});
await recordAuditEvent(prisma, {
action: "AGENT_PAUSE",
tenantId,
userId: user.sub,
entityType: "agent",
entityId: agent.id,
after: { pauseReason: pauseReason.name },
});
await notifyAgentChanged(tenantId, agent.id, "upsert");
return { state: "PAUSED" };
}
@Post("resume")
async resume(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub));
await withTenantContext(prisma, tenantId, async (tx) => {
await tx.agentPauseEvent.updateMany({
where: { tenantId, agentId: agent.id, endedAt: null },
data: { endedAt: new Date() },
});
await tx.agentStateEvent.create({ data: { tenantId, agentId: agent.id, state: "AVAILABLE" } });
await tx.agent.update({
where: { id: agent.id },
data: { state: "AVAILABLE", stateUpdatedAt: new Date() },
});
});
await recordAuditEvent(prisma, { action: "AGENT_RESUME", tenantId, userId: user.sub, entityType: "agent", entityId: agent.id });
await notifyAgentChanged(tenantId, agent.id, "upsert");
return { state: "AVAILABLE" };
}
}

View File

@@ -0,0 +1,109 @@
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 { CreateAgentDto } from "./dto/create-agent.dto";
import { notifyAgentChanged } from "./agent-sync.helper";
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("agents")
export class AgentsController {
/** Provisionamento: liga um usuário (login) a uma identidade de agente e,
* opcionalmente, a um ramal (agente.md secao 45). */
@RequirePermission("agents.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateAgentDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const agent = await withTenantContext(prisma, tenantId, (tx) =>
tx.agent.create({
data: {
tenantId,
userId: dto.userId,
extensionId: dto.extensionId,
name: dto.name,
maxNoAnswer: dto.maxNoAnswer ?? 3,
wrapUpTime: dto.wrapUpTime ?? 10,
},
}),
);
await recordAuditEvent(prisma, {
action: "AGENT_CREATE",
tenantId,
userId: user.sub,
entityType: "agent",
entityId: agent.id,
after: { name: agent.name, userId: agent.userId },
});
return agent;
}
@RequirePermission("agents.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
return withTenantContext(prisma, tenantId, (tx) =>
tx.agent.findMany({ where: { deletedAt: null }, orderBy: { name: "asc" } }),
);
}
@RequirePermission("agents.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const agent = await withTenantContext(prisma, tenantId, (tx) =>
tx.agent.findFirst({ where: { id, deletedAt: null } }),
);
if (!agent) {
throw new NotFoundException();
}
return agent;
}
@RequirePermission("agents.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.agent.updateMany({
where: { id, deletedAt: null },
data: { deletedAt: new Date(), enabled: false, state: "OFFLINE" },
}),
);
if (result.count === 0) {
throw new NotFoundException();
}
await recordAuditEvent(prisma, {
action: "AGENT_DELETE",
tenantId,
userId: user.sub,
entityType: "agent",
entityId: id,
});
await notifyAgentChanged(tenantId, id, "delete");
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { AgentsController } from "./agents.controller";
import { AgentsMeController } from "./agents-me.controller";
@Module({
controllers: [AgentsController, AgentsMeController],
})
export class AgentsModule {}

View File

@@ -0,0 +1,26 @@
import { IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from "class-validator";
export class CreateAgentDto {
@IsUUID()
userId!: string;
@IsOptional()
@IsUUID()
extensionId?: string;
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsInt()
@Min(0)
@Max(20)
maxNoAnswer?: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(3600)
wrapUpTime?: number;
}

View File

@@ -0,0 +1,6 @@
import { IsUUID } from "class-validator";
export class PauseDto {
@IsUUID()
pauseReasonId!: string;
}

View File

@@ -5,8 +5,19 @@ import { ExtensionsModule } from "./extensions/extensions.module";
import { TrunksModule } from "./trunks/trunks.module";
import { DialplanModule } from "./dialplan/dialplan.module";
import { QueuesModule } from "./queues/queues.module";
import { AgentsModule } from "./agents/agents.module";
import { PauseReasonsModule } from "./pause-reasons/pause-reasons.module";
@Module({
imports: [HealthModule, AuthModule, ExtensionsModule, TrunksModule, DialplanModule, QueuesModule],
imports: [
HealthModule,
AuthModule,
ExtensionsModule,
TrunksModule,
DialplanModule,
QueuesModule,
AgentsModule,
PauseReasonsModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,26 @@
import { IsBoolean, IsInt, IsOptional, IsString, Max, MaxLength, Min } from "class-validator";
export class CreatePauseReasonDto {
@IsString()
@MaxLength(80)
name!: string;
@IsString()
@MaxLength(40)
code!: string;
@IsOptional()
@IsString()
@MaxLength(255)
description?: string;
@IsOptional()
@IsInt()
@Min(0)
@Max(86400)
maxDuration?: number;
@IsOptional()
@IsBoolean()
paid?: boolean;
}

View File

@@ -0,0 +1,88 @@
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 { CreatePauseReasonDto } from "./dto/create-pause-reason.dto";
// Sem permission dedicada pra pausas (nao existe na secao 145 do
// agente.md) — reusa agents.* (pausas sao parte da gestao de agentes),
// mesmo criterio usado em Dialplan pra freeswitch.*.
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("pause-reasons")
export class PauseReasonsController {
@RequirePermission("agents.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreatePauseReasonDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const reason = await withTenantContext(prisma, tenantId, (tx) =>
tx.pauseReason.create({
data: {
tenantId,
name: dto.name,
code: dto.code,
description: dto.description,
maxDuration: dto.maxDuration,
paid: dto.paid ?? false,
},
}),
);
await recordAuditEvent(prisma, {
action: "PAUSE_REASON_CREATE",
tenantId,
userId: user.sub,
entityType: "pause_reason",
entityId: reason.id,
after: { name: reason.name, code: reason.code },
});
return reason;
}
@RequirePermission("agents.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
return withTenantContext(prisma, tenantId, (tx) => tx.pauseReason.findMany({ orderBy: { name: "asc" } }));
}
@RequirePermission("agents.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.pauseReason.updateMany({ where: { id, tenantId }, data: { enabled: false } }),
);
if (result.count === 0) {
throw new NotFoundException();
}
await recordAuditEvent(prisma, {
action: "PAUSE_REASON_DELETE",
tenantId,
userId: user.sub,
entityType: "pause_reason",
entityId: id,
});
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { PauseReasonsController } from "./pause-reasons.controller";
@Module({
controllers: [PauseReasonsController],
})
export class PauseReasonsModule {}

View File

@@ -0,0 +1,18 @@
import { IsInt, IsOptional, IsUUID, Max, Min } from "class-validator";
export class AddQueueAgentDto {
@IsUUID()
agentId!: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
level?: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
position?: number;
}

View File

@@ -0,0 +1,97 @@
import {
Body,
Controller,
Delete,
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 { AddQueueAgentDto } from "./dto/add-queue-agent.dto";
import { notifyTierChanged } from "../agents/agent-sync.helper";
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("queues/:queueId/agents")
export class QueueAgentsController {
@RequirePermission("queues.manage")
@Post()
async add(
@CurrentUser() user: AccessTokenClaims,
@Param("queueId") queueId: string,
@Body() dto: AddQueueAgentDto,
) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const [queue, agent] = await withTenantContext(prisma, tenantId, (tx) =>
Promise.all([
tx.queue.findFirst({ where: { id: queueId, tenantId, deletedAt: null } }),
tx.agent.findFirst({ where: { id: dto.agentId, tenantId, deletedAt: null } }),
]),
);
if (!queue || !agent) {
throw new NotFoundException("Fila ou agente nao encontrado");
}
const level = dto.level ?? 1;
const position = dto.position ?? 1;
const tier = await withTenantContext(prisma, tenantId, (tx) =>
tx.tier.upsert({
where: { queueId_agentId: { queueId, agentId: dto.agentId } },
update: { level, position },
create: { tenantId, queueId, agentId: dto.agentId, level, position },
}),
);
await recordAuditEvent(prisma, {
action: "TIER_ADD",
tenantId,
userId: user.sub,
entityType: "tier",
entityId: tier.id,
after: { queueId, agentId: dto.agentId, level, position },
});
await notifyTierChanged(tenantId, queueId, dto.agentId, "upsert", level, position);
return tier;
}
@RequirePermission("queues.manage")
@Delete(":agentId")
@HttpCode(HttpStatus.NO_CONTENT)
async remove(
@CurrentUser() user: AccessTokenClaims,
@Param("queueId") queueId: string,
@Param("agentId") agentId: string,
) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.tier.deleteMany({ where: { tenantId, queueId, agentId } }),
);
if (result.count === 0) {
throw new NotFoundException();
}
await recordAuditEvent(prisma, {
action: "TIER_REMOVE",
tenantId,
userId: user.sub,
entityType: "tier",
entityId: `${queueId}:${agentId}`,
});
await notifyTierChanged(tenantId, queueId, agentId, "delete");
}
}

View File

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

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

View File

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