feat: add telephony layer and asterisk-events worker
- packages/telephony: cliente AMI proprio sobre TCP puro (sem dependencia de terceiros pouco mantida) + interface TelephonyProvider + AsteriskTelephonyProvider (Originate, Hangup, QueuePause/Add/Remove, QueueStatus, ExtensionState, DeviceState, PJSIPShowEndpoints/Contacts, Reload, runCommand, stream de eventos). Testado contra o Asterisk real — o formato de resposta do Command mudou entre versoes do Asterisk (headers 'Output:' repetidos em vez de 'Response: Follows'/'--END COMMAND--'), corrigido apos inspecionar os bytes crus do protocolo - apps/asterisk-events: worker dedicado a manter a conexao AMI viva, normalizar eventos (Newchannel, DialBegin/End, Hangup, DeviceStateChange, ContactStatus, eventos de fila/agente), persistir ExtensionState no Postgres e publicar em Redis pub/sub para consumo em tempo real. Containerizado, alcanca o Asterisk (host network) via host.docker.internal a partir da rede bridge. Heartbeat no Redis para health check - packages/database: novos modelos Trunk, Extension, ExtensionState (migration aplicada) - packages/shared: secret-crypto.ts (AES-256-GCM para credenciais de trunk e senha SIP em repouso, master key externa ao banco) Testado ponta a ponta: chamada real originada -> eventos normalizados recebidos via Redis SUBSCRIBE, heartbeat renovando no TTL correto.
This commit is contained in:
@@ -60,7 +60,11 @@ RATE_LIMIT_LOGIN_MAX=5
|
|||||||
RATE_LIMIT_LOGIN_WINDOW_SECONDS=60
|
RATE_LIMIT_LOGIN_WINDOW_SECONDS=60
|
||||||
|
|
||||||
# --- Asterisk / AMI / ARI ------------------------------------------------
|
# --- Asterisk / AMI / ARI ------------------------------------------------
|
||||||
ASTERISK_HOST=127.0.0.1
|
# host.docker.internal (mapeado via extra_hosts no docker-compose.yml) —
|
||||||
|
# os serviços da aplicação (rede bridge) alcançam o Asterisk (network_mode:
|
||||||
|
# host) por aqui, já que "asterisk"/"127.0.0.1" não resolvem entre redes
|
||||||
|
# diferentes do Docker (ver docs/ARCHITECTURE.md 3.2.1).
|
||||||
|
ASTERISK_HOST=host.docker.internal
|
||||||
AMI_PORT=5038
|
AMI_PORT=5038
|
||||||
AMI_USERNAME=b2bcall_ami
|
AMI_USERNAME=b2bcall_ami
|
||||||
AMI_SECRET=CHANGE_ME_STRONG_PASSWORD
|
AMI_SECRET=CHANGE_ME_STRONG_PASSWORD
|
||||||
|
|||||||
18
TODO.md
18
TODO.md
@@ -92,11 +92,23 @@ mestre original (`agente.md`, seções 90-93).
|
|||||||
externo — trocar MailerService por nodemailer quando houver
|
externo — trocar MailerService por nodemailer quando houver
|
||||||
|
|
||||||
## Fase 4 — Telefonia (camada de aplicação)
|
## Fase 4 — Telefonia (camada de aplicação)
|
||||||
- [ ] packages/telephony: TelephonyProvider + AsteriskTelephonyProvider
|
- [x] packages/telephony: cliente AMI próprio (TCP raw, sem dependência de
|
||||||
- [ ] apps/asterisk-events (AMI listener, normalização, persistência, pub/sub)
|
terceiros) + TelephonyProvider + AsteriskTelephonyProvider — testado
|
||||||
|
contra o Asterisk real: login, PJSIPShowEndpoints/QueueStatus (lista
|
||||||
|
vazia tratada corretamente), Command (parsing do formato real do
|
||||||
|
Asterisk 22: múltiplos headers "Output:", não mais "Follows"/"--END
|
||||||
|
COMMAND--"), Originate, stream de eventos
|
||||||
|
- [x] apps/asterisk-events (worker dedicado, containerizado) — conecta via
|
||||||
|
host.docker.internal (rede bridge -> Asterisk em host network),
|
||||||
|
normaliza eventos relevantes, persiste ExtensionState (Postgres),
|
||||||
|
publica em Redis pub/sub (b2bcall:events:asterisk e :extensions),
|
||||||
|
heartbeat para health check — testado ponta a ponta com chamada real
|
||||||
|
- [x] Schema Prisma: Trunk, Extension, ExtensionState + criptografia de
|
||||||
|
segredos AES-256-GCM (packages/shared/secret-crypto.ts)
|
||||||
- [ ] CRUD Troncos (com CPS máximo, ACL, teste de status)
|
- [ ] CRUD Troncos (com CPS máximo, ACL, teste de status)
|
||||||
- [ ] CRUD Ramais (senha SIP gerada, reset, status tempo real)
|
- [ ] CRUD Ramais (senha SIP gerada, reset, status tempo real)
|
||||||
- [ ] Painel visual de Ramais (WebSocket, prioridade de cores)
|
- [ ] Painel visual de Ramais (WebSocket, prioridade de cores) — backend
|
||||||
|
(gateway WS) pendente; ExtensionState/pub-sub já prontos como base
|
||||||
- [ ] Dialplan estruturado (versionado, modo Advanced, validação+rollback)
|
- [ ] Dialplan estruturado (versionado, modo Advanced, validação+rollback)
|
||||||
- [ ] Administração do Asterisk (abas: geral, pjsip, rtp, filas, cdr, cel, logs,
|
- [ ] Administração do Asterisk (abas: geral, pjsip, rtp, filas, cdr, cel, logs,
|
||||||
ami, ari, modules, diagnóstico com allowlist de comandos)
|
ami, ari, modules, diagnóstico com allowlist de comandos)
|
||||||
|
|||||||
22
apps/asterisk-events/package.json
Normal file
22
apps/asterisk-events/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@b2bcall/asterisk-events",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"start:dev": "ts-node src/main.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@b2bcall/database": "workspace:*",
|
||||||
|
"@b2bcall/telephony": "workspace:*",
|
||||||
|
"ioredis": "^5.4.2",
|
||||||
|
"pino": "^9.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/asterisk-events/src/logger.ts
Normal file
7
apps/asterisk-events/src/logger.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
// Logs estruturados JSON (agente.md seção 60) — nunca loga segredos.
|
||||||
|
export const logger = pino({
|
||||||
|
level: process.env.LOG_LEVEL ?? 'info',
|
||||||
|
base: { service: 'b2bcall-asterisk-events' },
|
||||||
|
});
|
||||||
83
apps/asterisk-events/src/main.ts
Normal file
83
apps/asterisk-events/src/main.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { AsteriskTelephonyProvider, type AmiMessage } from '@b2bcall/telephony';
|
||||||
|
import { PrismaClient } from '@b2bcall/database';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { logger } from './logger';
|
||||||
|
import { extractExtensionStatePatch, RELEVANT_EVENTS } from './normalize';
|
||||||
|
|
||||||
|
const REDIS_CHANNEL_EXTENSIONS = 'b2bcall:events:extensions';
|
||||||
|
const REDIS_CHANNEL_ASTERISK = 'b2bcall:events:asterisk';
|
||||||
|
const HEARTBEAT_KEY = 'b2bcall:asterisk-events:heartbeat';
|
||||||
|
const HEARTBEAT_TTL_SECONDS = 15;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const redis = new Redis(process.env.REDIS_URL!);
|
||||||
|
|
||||||
|
const provider = new AsteriskTelephonyProvider({
|
||||||
|
host: process.env.ASTERISK_HOST!,
|
||||||
|
amiPort: Number(process.env.AMI_PORT ?? 5038),
|
||||||
|
amiUsername: process.env.AMI_USERNAME!,
|
||||||
|
amiSecret: process.env.AMI_SECRET!,
|
||||||
|
reconnect: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
provider.onEvent((event: AmiMessage) => {
|
||||||
|
void handleEvent(event).catch((err) => logger.error({ err, event }, 'Falha ao processar evento AMI'));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleEvent(event: AmiMessage) {
|
||||||
|
if (!event.Event || !RELEVANT_EVENTS.has(event.Event)) return;
|
||||||
|
|
||||||
|
await redis.publish(REDIS_CHANNEL_ASTERISK, JSON.stringify(event));
|
||||||
|
|
||||||
|
const patch = extractExtensionStatePatch(event);
|
||||||
|
if (!patch) return;
|
||||||
|
|
||||||
|
const updated = await prisma.extensionState.upsert({
|
||||||
|
where: { extension: patch.extension },
|
||||||
|
create: {
|
||||||
|
extension: patch.extension,
|
||||||
|
deviceState: patch.deviceState,
|
||||||
|
contactStatus: patch.contactStatus,
|
||||||
|
contactUri: patch.contactUri,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...(patch.deviceState !== undefined ? { deviceState: patch.deviceState } : {}),
|
||||||
|
...(patch.contactStatus !== undefined ? { contactStatus: patch.contactStatus } : {}),
|
||||||
|
...(patch.contactUri !== undefined ? { contactUri: patch.contactUri } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await redis.publish(REDIS_CHANNEL_EXTENSIONS, JSON.stringify(updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function heartbeat() {
|
||||||
|
try {
|
||||||
|
await redis.set(HEARTBEAT_KEY, new Date().toISOString(), 'EX', HEARTBEAT_TTL_SECONDS);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'Falha ao gravar heartbeat no Redis');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Conectando ao AMI...');
|
||||||
|
await provider.connect();
|
||||||
|
logger.info('Conectado ao AMI. Escutando eventos.');
|
||||||
|
|
||||||
|
await heartbeat();
|
||||||
|
setInterval(() => void heartbeat(), (HEARTBEAT_TTL_SECONDS * 1000) / 2);
|
||||||
|
|
||||||
|
const shutdown = async () => {
|
||||||
|
logger.info('Encerrando apps/asterisk-events...');
|
||||||
|
provider.disconnect();
|
||||||
|
await redis.quit();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', () => void shutdown());
|
||||||
|
process.on('SIGINT', () => void shutdown());
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
logger.error({ err }, 'Erro fatal ao iniciar apps/asterisk-events');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
61
apps/asterisk-events/src/normalize.ts
Normal file
61
apps/asterisk-events/src/normalize.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { AmiMessage } from '@b2bcall/telephony';
|
||||||
|
|
||||||
|
export interface ExtensionStatePatch {
|
||||||
|
extension: string;
|
||||||
|
deviceState?: string;
|
||||||
|
contactStatus?: string;
|
||||||
|
contactUri?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrai uma atualização de estado de ramal a partir de um evento AMI bruto,
|
||||||
|
* ou `null` se o evento não for relevante para o painel de ramais. Os
|
||||||
|
* ramais PJSIP são nomeados pelo próprio número (ver ExtensionsService), por
|
||||||
|
* isso "PJSIP/1001" -> "1001" e EndpointName já É o número do ramal.
|
||||||
|
*/
|
||||||
|
export function extractExtensionStatePatch(event: AmiMessage): ExtensionStatePatch | null {
|
||||||
|
switch (event.Event) {
|
||||||
|
case 'DeviceStateChange': {
|
||||||
|
const device = event.Device ?? '';
|
||||||
|
const [tech, exten] = device.split('/');
|
||||||
|
if (tech !== 'PJSIP' || !exten) return null;
|
||||||
|
return { extension: exten, deviceState: event.State };
|
||||||
|
}
|
||||||
|
case 'ContactStatus': {
|
||||||
|
const endpoint = event.EndpointName;
|
||||||
|
if (!endpoint) return null;
|
||||||
|
return {
|
||||||
|
extension: endpoint,
|
||||||
|
contactStatus: event.ContactStatus,
|
||||||
|
contactUri: event.URI,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eventos administrativos/de chamada que a spec pede para consumir (agente.md
|
||||||
|
// seção 8), além dos dois acima já usados para estado de ramal. Persistência
|
||||||
|
// rica (call_events/queue_events) chega nas Fases 6/7 quando as tabelas de
|
||||||
|
// campanha/fila existirem — por ora, apenas repassados ao Redis para quem
|
||||||
|
// quiser consumir em tempo real (ex.: futura Fase 5 de monitoramento de filas).
|
||||||
|
export const RELEVANT_EVENTS = new Set([
|
||||||
|
'Newchannel',
|
||||||
|
'DialBegin',
|
||||||
|
'DialEnd',
|
||||||
|
'BridgeEnter',
|
||||||
|
'BridgeLeave',
|
||||||
|
'Hangup',
|
||||||
|
'Newstate',
|
||||||
|
'DeviceStateChange',
|
||||||
|
'QueueMemberStatus',
|
||||||
|
'QueueMemberPause',
|
||||||
|
'AgentConnect',
|
||||||
|
'AgentComplete',
|
||||||
|
'QueueCallerJoin',
|
||||||
|
'QueueCallerLeave',
|
||||||
|
'QueueCallerAbandon',
|
||||||
|
'ContactStatus',
|
||||||
|
'PeerStatus',
|
||||||
|
]);
|
||||||
14
apps/asterisk-events/tsconfig.json
Normal file
14
apps/asterisk-events/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"target": "ES2022",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -131,6 +131,10 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- b2bcall-net
|
- b2bcall-net
|
||||||
|
# Permite alcançar o Asterisk (network_mode: host) via AMI/ARI a partir
|
||||||
|
# da rede bridge — ver docs/ARCHITECTURE.md 3.2.1.
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -158,3 +162,37 @@ services:
|
|||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 300M
|
memory: 300M
|
||||||
|
|
||||||
|
asterisk-events:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: infrastructure/docker/asterisk-events.Dockerfile
|
||||||
|
image: b2bcall-asterisk-events:0.1.0
|
||||||
|
container_name: b2bcall-asterisk-events
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- b2bcall-net
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
asterisk:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
ASTERISK_HOST: host.docker.internal
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 150M
|
||||||
|
|||||||
37
infrastructure/docker/asterisk-events.Dockerfile
Normal file
37
infrastructure/docker/asterisk-events.Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# apps/asterisk-events — worker dedicado à conexão AMI (monorepo pnpm).
|
||||||
|
FROM node:24-slim AS build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.24.0 --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
|
COPY apps/asterisk-events/package.json apps/asterisk-events/package.json
|
||||||
|
COPY packages/database/package.json packages/database/package.json
|
||||||
|
COPY packages/telephony/package.json packages/telephony/package.json
|
||||||
|
COPY packages/shared/package.json packages/shared/package.json
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY packages/shared packages/shared
|
||||||
|
COPY packages/telephony packages/telephony
|
||||||
|
COPY packages/database packages/database
|
||||||
|
COPY apps/asterisk-events apps/asterisk-events
|
||||||
|
|
||||||
|
RUN pnpm --filter @b2bcall/shared build \
|
||||||
|
&& pnpm --filter @b2bcall/telephony build \
|
||||||
|
&& pnpm --filter @b2bcall/database build \
|
||||||
|
&& pnpm --filter @b2bcall/asterisk-events build
|
||||||
|
|
||||||
|
# --- runtime -------------------------------------------------------------
|
||||||
|
FROM node:24-slim AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& groupadd -r b2bcall && useradd -r -g b2bcall b2bcall
|
||||||
|
|
||||||
|
COPY --from=build /app /app
|
||||||
|
USER b2bcall
|
||||||
|
|
||||||
|
CMD ["node", "apps/asterisk-events/dist/main.js"]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TrunkType" AS ENUM ('IP', 'AUTH', 'REGISTRATION');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DtmfMode" AS ENUM ('rfc4733', 'info', 'inband', 'auto');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "trunks" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" "TrunkType" NOT NULL,
|
||||||
|
"host" TEXT NOT NULL,
|
||||||
|
"port" INTEGER NOT NULL DEFAULT 5060,
|
||||||
|
"transport" TEXT NOT NULL DEFAULT 'udp',
|
||||||
|
"username" TEXT,
|
||||||
|
"secret_encrypted" TEXT,
|
||||||
|
"from_user" TEXT,
|
||||||
|
"from_domain" TEXT,
|
||||||
|
"contact_user" TEXT,
|
||||||
|
"outbound_proxy" TEXT,
|
||||||
|
"context" TEXT NOT NULL DEFAULT 'outbound',
|
||||||
|
"caller_id" TEXT,
|
||||||
|
"codecs" TEXT[] DEFAULT ARRAY['ulaw', 'alaw']::TEXT[],
|
||||||
|
"dtmf_mode" "DtmfMode" NOT NULL DEFAULT 'rfc4733',
|
||||||
|
"qualify_frequency" INTEGER NOT NULL DEFAULT 60,
|
||||||
|
"max_channels" INTEGER,
|
||||||
|
"max_cps" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"allowed_ips" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "trunks_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "extensions" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"number" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"sip_password_encrypted" TEXT NOT NULL,
|
||||||
|
"caller_id" TEXT,
|
||||||
|
"context" TEXT NOT NULL DEFAULT 'b2bcall-agents',
|
||||||
|
"codecs" TEXT[] DEFAULT ARRAY['ulaw', 'alaw']::TEXT[],
|
||||||
|
"transport" TEXT NOT NULL DEFAULT 'udp',
|
||||||
|
"max_contacts" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"qualify_frequency" INTEGER NOT NULL DEFAULT 60,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "extensions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "extension_states" (
|
||||||
|
"extension" TEXT NOT NULL,
|
||||||
|
"device_state" TEXT,
|
||||||
|
"contact_status" TEXT,
|
||||||
|
"contact_uri" TEXT,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "extension_states_pkey" PRIMARY KEY ("extension")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "trunks_name_key" ON "trunks"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "extensions_number_key" ON "extensions"("number");
|
||||||
@@ -131,3 +131,84 @@ model AuditLog {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("audit_logs")
|
@@map("audit_logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Fase 4 — Telefonia. CRUD da aplicação; os objetos PJSIP correspondentes
|
||||||
|
// (ps_endpoints/ps_auths/ps_aors/ps_endpoint_id_ips/ps_registrations) são
|
||||||
|
// provisionados no schema "asterisk" pelo TrunksService/ExtensionsService
|
||||||
|
// (packages/telephony), nunca editados manualmente.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
enum TrunkType {
|
||||||
|
IP
|
||||||
|
AUTH
|
||||||
|
REGISTRATION
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DtmfMode {
|
||||||
|
rfc4733
|
||||||
|
info
|
||||||
|
inband
|
||||||
|
auto
|
||||||
|
}
|
||||||
|
|
||||||
|
model Trunk {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
type TrunkType
|
||||||
|
host String
|
||||||
|
port Int @default(5060)
|
||||||
|
transport String @default("udp")
|
||||||
|
username String?
|
||||||
|
// Segredo cifrado em repouso (AES-256-GCM, master key fora do banco —
|
||||||
|
// agente.md seção 55). Nunca retornado em claro pela API após salvar.
|
||||||
|
secretEncrypted String? @map("secret_encrypted")
|
||||||
|
fromUser String? @map("from_user")
|
||||||
|
fromDomain String? @map("from_domain")
|
||||||
|
contactUser String? @map("contact_user")
|
||||||
|
outboundProxy String? @map("outbound_proxy")
|
||||||
|
context String @default("outbound")
|
||||||
|
callerId String? @map("caller_id")
|
||||||
|
codecs String[] @default(["ulaw", "alaw"])
|
||||||
|
dtmfMode DtmfMode @default(rfc4733) @map("dtmf_mode")
|
||||||
|
qualifyFrequency Int @default(60) @map("qualify_frequency")
|
||||||
|
maxChannels Int? @map("max_channels")
|
||||||
|
maxCps Int @default(5) @map("max_cps")
|
||||||
|
allowedIps String[] @default([]) @map("allowed_ips")
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("trunks")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Extension {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
number String @unique
|
||||||
|
name String
|
||||||
|
sipPasswordEncrypted String @map("sip_password_encrypted")
|
||||||
|
callerId String? @map("caller_id")
|
||||||
|
context String @default("b2bcall-agents")
|
||||||
|
codecs String[] @default(["ulaw", "alaw"])
|
||||||
|
transport String @default("udp")
|
||||||
|
maxContacts Int @default(1) @map("max_contacts")
|
||||||
|
qualifyFrequency Int @default(60) @map("qualify_frequency")
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("extensions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Último estado conhecido de cada ramal — alimentado por
|
||||||
|
// apps/asterisk-events a partir de DeviceStateChange/ContactStatus.
|
||||||
|
// Fonte do painel de monitoramento (nunca polling do Asterisk no frontend).
|
||||||
|
model ExtensionState {
|
||||||
|
extension String @id
|
||||||
|
deviceState String? @map("device_state")
|
||||||
|
contactStatus String? @map("contact_status")
|
||||||
|
contactUri String? @map("contact_uri")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("extension_states")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './permissions';
|
export * from './permissions';
|
||||||
export * from './generate-password';
|
export * from './generate-password';
|
||||||
|
export * from './secret-crypto';
|
||||||
|
|||||||
37
packages/shared/src/secret-crypto.ts
Normal file
37
packages/shared/src/secret-crypto.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
// Criptografia de segredos em repouso (credenciais de trunk SIP, senhas de
|
||||||
|
// ramal) — AES-256-GCM com master key externa ao banco (agente.md seções
|
||||||
|
// 55/97). Formato do texto cifrado: base64(iv[12] || authTag[16] || ciphertext).
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const IV_LENGTH = 12;
|
||||||
|
const AUTH_TAG_LENGTH = 16;
|
||||||
|
|
||||||
|
function loadMasterKey(masterKeyBase64: string): Buffer {
|
||||||
|
const key = Buffer.from(masterKeyBase64, 'base64');
|
||||||
|
if (key.length !== 32) {
|
||||||
|
throw new Error('SECRETS_MASTER_KEY deve ser uma chave base64 de 32 bytes (AES-256).');
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptSecret(plainText: string, masterKeyBase64: string): string {
|
||||||
|
const key = loadMasterKey(masterKeyBase64);
|
||||||
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
return Buffer.concat([iv, authTag, ciphertext]).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptSecret(encoded: string, masterKeyBase64: string): string {
|
||||||
|
const key = loadMasterKey(masterKeyBase64);
|
||||||
|
const raw = Buffer.from(encoded, 'base64');
|
||||||
|
const iv = raw.subarray(0, IV_LENGTH);
|
||||||
|
const authTag = raw.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
||||||
|
const ciphertext = raw.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
||||||
|
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
||||||
|
}
|
||||||
15
packages/telephony/package.json
Normal file
15
packages/telephony/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@b2bcall/telephony",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
270
packages/telephony/src/ami-client.ts
Normal file
270
packages/telephony/src/ami-client.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { Socket, createConnection } from 'node:net';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
export type AmiMessage = Record<string, string>;
|
||||||
|
|
||||||
|
export interface AmiClientOptions {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
secret: string;
|
||||||
|
/** Reconecta automaticamente após queda de conexão. */
|
||||||
|
reconnect?: boolean;
|
||||||
|
reconnectDelayMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGE_TERMINATOR = '\r\n\r\n';
|
||||||
|
const COMMAND_END_MARKER = '--END COMMAND--';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cliente AMI (Asterisk Manager Interface) implementado sobre um socket TCP
|
||||||
|
* puro — sem depender de pacotes de terceiros pouco mantidos. Suporta:
|
||||||
|
* - login/logoff
|
||||||
|
* - ações simples (uma Response só)
|
||||||
|
* - ações "list-style" que retornam uma série de Events terminada por um
|
||||||
|
* evento "*Complete" (PJSIPShowEndpoints, QueueStatus, ...)
|
||||||
|
* - ação Command (CLI via AMI), cuja resposta vem como texto cru entre
|
||||||
|
* "Response: Follows" e "--END COMMAND--"
|
||||||
|
* - stream de eventos assíncronos (Newchannel, Hangup, QueueMemberStatus...)
|
||||||
|
* via EventEmitter, consumido por apps/asterisk-events.
|
||||||
|
*/
|
||||||
|
export class AmiClient extends EventEmitter {
|
||||||
|
private socket: Socket | null = null;
|
||||||
|
private buffer = '';
|
||||||
|
private connected = false;
|
||||||
|
private loggedIn = false;
|
||||||
|
private readonly pending = new Map<
|
||||||
|
string,
|
||||||
|
{ resolve: (msg: AmiMessage) => void; reject: (err: Error) => void }
|
||||||
|
>();
|
||||||
|
private readonly collecting = new Map<
|
||||||
|
string,
|
||||||
|
{ events: AmiMessage[]; resolve: (events: AmiMessage[]) => void; completionSuffix: string }
|
||||||
|
>();
|
||||||
|
private rawFollowsBuffer: string[] | null = null;
|
||||||
|
private rawFollowsActionId: string | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly options: AmiClientOptions) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const socket = createConnection({ host: this.options.host, port: this.options.port });
|
||||||
|
this.socket = socket;
|
||||||
|
|
||||||
|
const onError = (err: Error) => {
|
||||||
|
this.connected = false;
|
||||||
|
if (!this.loggedIn) reject(err);
|
||||||
|
this.emit('error', err);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.once('error', onError);
|
||||||
|
socket.once('connect', () => {
|
||||||
|
this.connected = true;
|
||||||
|
});
|
||||||
|
socket.on('data', (chunk) => this.handleData(chunk.toString('utf8')));
|
||||||
|
socket.on('close', () => {
|
||||||
|
this.connected = false;
|
||||||
|
this.loggedIn = false;
|
||||||
|
this.emit('disconnected');
|
||||||
|
if (this.options.reconnect) {
|
||||||
|
setTimeout(() => this.connect().catch(() => undefined), this.options.reconnectDelayMs ?? 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// O banner "Asterisk Call Manager/x.y.z\r\n" chega antes de qualquer
|
||||||
|
// bloco Key:Value — aguardamos a primeira linha antes de logar.
|
||||||
|
const onceBanner = () => {
|
||||||
|
this.login().then(resolve).catch(reject);
|
||||||
|
};
|
||||||
|
socket.once('data', onceBanner);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async login(): Promise<void> {
|
||||||
|
const response = await this.sendAction({
|
||||||
|
Action: 'Login',
|
||||||
|
Username: this.options.username,
|
||||||
|
Secret: this.options.secret,
|
||||||
|
});
|
||||||
|
if ((response.Response ?? '').toLowerCase() !== 'success') {
|
||||||
|
throw new Error(`Falha no login AMI: ${response.Message ?? 'motivo desconhecido'}`);
|
||||||
|
}
|
||||||
|
this.loggedIn = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.socket?.end();
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.connected && this.loggedIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleData(chunk: string): void {
|
||||||
|
this.buffer += chunk;
|
||||||
|
|
||||||
|
// Modo especial: coletando texto cru de uma resposta "Follows" (ação
|
||||||
|
// Command), que não usa o formato Key:Value linha a linha.
|
||||||
|
if (this.rawFollowsBuffer !== null) {
|
||||||
|
const idx = this.buffer.indexOf(COMMAND_END_MARKER);
|
||||||
|
if (idx === -1) return;
|
||||||
|
const before = this.buffer.slice(0, idx);
|
||||||
|
this.rawFollowsBuffer.push(before);
|
||||||
|
this.buffer = this.buffer.slice(idx + COMMAND_END_MARKER.length);
|
||||||
|
const actionId = this.rawFollowsActionId;
|
||||||
|
const text = this.rawFollowsBuffer.join('');
|
||||||
|
this.rawFollowsBuffer = null;
|
||||||
|
this.rawFollowsActionId = null;
|
||||||
|
// Consome até a próxima linha em branco (fim do bloco de resposta).
|
||||||
|
const blankIdx = this.buffer.indexOf('\r\n\r\n');
|
||||||
|
if (blankIdx !== -1) this.buffer = this.buffer.slice(blankIdx + 4);
|
||||||
|
if (actionId) this.resolvePending(actionId, { Response: 'Follows', ActionID: actionId, __output: text });
|
||||||
|
}
|
||||||
|
|
||||||
|
let terminatorIdx: number;
|
||||||
|
while ((terminatorIdx = this.buffer.indexOf(MESSAGE_TERMINATOR)) !== -1) {
|
||||||
|
const raw = this.buffer.slice(0, terminatorIdx);
|
||||||
|
this.buffer = this.buffer.slice(terminatorIdx + MESSAGE_TERMINATOR.length);
|
||||||
|
if (!raw.trim()) continue;
|
||||||
|
this.processBlock(raw);
|
||||||
|
if (this.rawFollowsBuffer !== null) {
|
||||||
|
// A ação Command começou um bloco "Follows" no meio do que sobrou
|
||||||
|
// do buffer — reprocessa recursivamente o restante.
|
||||||
|
this.handleData('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private processBlock(raw: string): void {
|
||||||
|
const lines = raw.split('\r\n');
|
||||||
|
const msg: AmiMessage = {};
|
||||||
|
const outputLines: string[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const sepIdx = line.indexOf(':');
|
||||||
|
if (sepIdx === -1) continue;
|
||||||
|
const key = line.slice(0, sepIdx).trim();
|
||||||
|
const value = line.slice(sepIdx + 1).trim();
|
||||||
|
// A ação Command (Asterisk 22+) repete o header "Output:" uma vez por
|
||||||
|
// linha de saída, em vez do formato legado "Response: Follows" +
|
||||||
|
// texto cru terminado em "--END COMMAND--".
|
||||||
|
if (key === 'Output') {
|
||||||
|
outputLines.push(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
msg[key] = value;
|
||||||
|
}
|
||||||
|
if (outputLines.length > 0) msg.__output = outputLines.join('\n');
|
||||||
|
|
||||||
|
if (msg.Response === 'Follows') {
|
||||||
|
// Compatibilidade com o formato legado de versões antigas do
|
||||||
|
// Asterisk, caso apareça.
|
||||||
|
this.rawFollowsBuffer = [];
|
||||||
|
this.rawFollowsActionId = msg.ActionID ?? null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.Response) {
|
||||||
|
if (msg.ActionID) this.resolvePending(msg.ActionID, msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.Event) {
|
||||||
|
this.emit('event', msg);
|
||||||
|
if (msg.ActionID && this.collecting.has(msg.ActionID)) {
|
||||||
|
const collector = this.collecting.get(msg.ActionID)!;
|
||||||
|
if (msg.Event.endsWith(collector.completionSuffix)) {
|
||||||
|
this.collecting.delete(msg.ActionID);
|
||||||
|
collector.resolve(collector.events);
|
||||||
|
} else {
|
||||||
|
collector.events.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePending(actionId: string, msg: AmiMessage): void {
|
||||||
|
const pending = this.pending.get(actionId);
|
||||||
|
if (!pending) return;
|
||||||
|
this.pending.delete(actionId);
|
||||||
|
pending.resolve(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Envia uma ação e resolve com a Response única (ações sem lista de eventos). */
|
||||||
|
sendAction(action: AmiMessage): Promise<AmiMessage> {
|
||||||
|
if (!this.socket) throw new Error('AMI não conectado.');
|
||||||
|
const actionId = action.ActionID ?? randomUUID();
|
||||||
|
const payload = { ...action, ActionID: actionId };
|
||||||
|
|
||||||
|
return new Promise<AmiMessage>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.pending.delete(actionId);
|
||||||
|
reject(new Error(`Timeout aguardando resposta AMI para ${action.Action}`));
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
this.pending.set(actionId, {
|
||||||
|
resolve: (msg) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(msg);
|
||||||
|
},
|
||||||
|
reject: (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.write(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envia uma ação "list-style" (PJSIPShowEndpoints, QueueStatus, ...) e
|
||||||
|
* coleta os Events associados até o evento de conclusão (ex.:
|
||||||
|
* "EndpointListComplete"), identificado pelo sufixo "Complete".
|
||||||
|
*/
|
||||||
|
async sendActionCollectEvents(action: AmiMessage, completionSuffix = 'Complete'): Promise<AmiMessage[]> {
|
||||||
|
if (!this.socket) throw new Error('AMI não conectado.');
|
||||||
|
const actionId = action.ActionID ?? randomUUID();
|
||||||
|
const payload = { ...action, ActionID: actionId };
|
||||||
|
|
||||||
|
const eventsPromise = new Promise<AmiMessage[]>((resolve) => {
|
||||||
|
this.collecting.set(actionId, { events: [], resolve, completionSuffix });
|
||||||
|
});
|
||||||
|
|
||||||
|
const ack = await this.sendAction(payload);
|
||||||
|
if ((ack.Response ?? '').toLowerCase() !== 'success') {
|
||||||
|
this.collecting.delete(actionId);
|
||||||
|
// O Asterisk responde "Response: Error" (não uma lista vazia) quando
|
||||||
|
// não há nenhum item a listar (ex.: "No endpoints found", "No queues
|
||||||
|
// found") — isso é uma lista vazia legítima, não uma falha real.
|
||||||
|
if (/no .* found/i.test(ack.Message ?? '')) return [];
|
||||||
|
throw new Error(`Ação ${action.Action} rejeitada: ${ack.Message ?? 'motivo desconhecido'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.race([
|
||||||
|
eventsPromise,
|
||||||
|
new Promise<AmiMessage[]>((_, reject) =>
|
||||||
|
setTimeout(() => {
|
||||||
|
this.collecting.delete(actionId);
|
||||||
|
reject(new Error(`Timeout coletando eventos de ${action.Action}`));
|
||||||
|
}, 10_000),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Executa um comando de CLI via AMI (Action: Command). Retorna o texto cru. */
|
||||||
|
async runCommand(command: string): Promise<string> {
|
||||||
|
const response = await this.sendAction({ Action: 'Command', Command: command });
|
||||||
|
return response.__output ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private write(action: AmiMessage): void {
|
||||||
|
const lines = Object.entries(action).map(([k, v]) => `${k}: ${v}`);
|
||||||
|
this.socket!.write(lines.join('\r\n') + '\r\n\r\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
178
packages/telephony/src/asterisk-telephony-provider.ts
Normal file
178
packages/telephony/src/asterisk-telephony-provider.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { AmiClient, type AmiMessage } from './ami-client';
|
||||||
|
import type {
|
||||||
|
OriginateParams,
|
||||||
|
PjsipContactSummary,
|
||||||
|
PjsipEndpointSummary,
|
||||||
|
QueuePauseParams,
|
||||||
|
QueueStatusSummary,
|
||||||
|
TelephonyProvider,
|
||||||
|
} from './telephony-provider.interface';
|
||||||
|
|
||||||
|
export interface AsteriskTelephonyProviderOptions {
|
||||||
|
host: string;
|
||||||
|
amiPort: number;
|
||||||
|
amiUsername: string;
|
||||||
|
amiSecret: string;
|
||||||
|
reconnect?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AsteriskTelephonyProvider implements TelephonyProvider {
|
||||||
|
private readonly client: AmiClient;
|
||||||
|
|
||||||
|
constructor(options: AsteriskTelephonyProviderOptions) {
|
||||||
|
this.client = new AmiClient({
|
||||||
|
host: options.host,
|
||||||
|
port: options.amiPort,
|
||||||
|
username: options.amiUsername,
|
||||||
|
secret: options.amiSecret,
|
||||||
|
reconnect: options.reconnect ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): Promise<void> {
|
||||||
|
return this.client.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.client.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.client.isConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
async originate(params: OriginateParams): Promise<AmiMessage> {
|
||||||
|
const action: AmiMessage = {
|
||||||
|
Action: 'Originate',
|
||||||
|
Channel: params.channel,
|
||||||
|
Async: params.async === false ? 'false' : 'true',
|
||||||
|
Timeout: String(params.timeoutMs ?? 30_000),
|
||||||
|
};
|
||||||
|
if (params.context) action.Context = params.context;
|
||||||
|
if (params.exten) action.Exten = params.exten;
|
||||||
|
if (params.priority !== undefined) action.Priority = String(params.priority);
|
||||||
|
if (params.application) action.Application = params.application;
|
||||||
|
if (params.data) action.Data = params.data;
|
||||||
|
if (params.callerId) action.CallerID = params.callerId;
|
||||||
|
if (params.variables) {
|
||||||
|
// AMI aceita múltiplos headers "Variable" — usamos um por par k=v.
|
||||||
|
Object.entries(params.variables).forEach(([k, v], i) => {
|
||||||
|
action[`Variable${i > 0 ? `-${i}` : ''}`] = `${k}=${v}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.client.sendAction(action);
|
||||||
|
if ((response.Response ?? '').toLowerCase() !== 'success') {
|
||||||
|
throw new Error(`Originate falhou: ${response.Message ?? 'motivo desconhecido'}`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async hangup(channel: string, cause?: number): Promise<void> {
|
||||||
|
const action: AmiMessage = { Action: 'Hangup', Channel: channel };
|
||||||
|
if (cause !== undefined) action.Cause = String(cause);
|
||||||
|
const response = await this.client.sendAction(action);
|
||||||
|
if ((response.Response ?? '').toLowerCase() !== 'success') {
|
||||||
|
throw new Error(`Hangup falhou: ${response.Message ?? 'motivo desconhecido'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async queuePause(params: QueuePauseParams): Promise<void> {
|
||||||
|
const action: AmiMessage = {
|
||||||
|
Action: 'QueuePause',
|
||||||
|
Interface: params.interface,
|
||||||
|
Paused: params.paused ? 'true' : 'false',
|
||||||
|
};
|
||||||
|
if (params.queue) action.Queue = params.queue;
|
||||||
|
if (params.reason) action.Reason = params.reason;
|
||||||
|
const response = await this.client.sendAction(action);
|
||||||
|
if ((response.Response ?? '').toLowerCase() !== 'success') {
|
||||||
|
throw new Error(`QueuePause falhou: ${response.Message ?? 'motivo desconhecido'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async queueAdd(queue: string, iface: string, opts?: { penalty?: number; memberName?: string }): Promise<void> {
|
||||||
|
const action: AmiMessage = { Action: 'QueueAdd', Queue: queue, Interface: iface };
|
||||||
|
if (opts?.penalty !== undefined) action.Penalty = String(opts.penalty);
|
||||||
|
if (opts?.memberName) action.MemberName = opts.memberName;
|
||||||
|
const response = await this.client.sendAction(action);
|
||||||
|
if ((response.Response ?? '').toLowerCase() !== 'success') {
|
||||||
|
throw new Error(`QueueAdd falhou: ${response.Message ?? 'motivo desconhecido'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async queueRemove(queue: string, iface: string): Promise<void> {
|
||||||
|
const response = await this.client.sendAction({ Action: 'QueueRemove', Queue: queue, Interface: iface });
|
||||||
|
if ((response.Response ?? '').toLowerCase() !== 'success') {
|
||||||
|
throw new Error(`QueueRemove falhou: ${response.Message ?? 'motivo desconhecido'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async queueStatus(queue?: string): Promise<QueueStatusSummary[]> {
|
||||||
|
const action: AmiMessage = { Action: 'QueueStatus' };
|
||||||
|
if (queue) action.Queue = queue;
|
||||||
|
const events = await this.client.sendActionCollectEvents(action, 'StatusComplete');
|
||||||
|
|
||||||
|
const byQueue = new Map<string, QueueStatusSummary>();
|
||||||
|
for (const evt of events) {
|
||||||
|
const qName = evt.Queue;
|
||||||
|
if (!qName) continue;
|
||||||
|
if (!byQueue.has(qName)) {
|
||||||
|
byQueue.set(qName, { queue: qName, members: [], entries: [] });
|
||||||
|
}
|
||||||
|
const summary = byQueue.get(qName)!;
|
||||||
|
if (evt.Event === 'QueueParams') summary.calls = evt.Calls;
|
||||||
|
else if (evt.Event === 'QueueMember') summary.members.push(evt);
|
||||||
|
else if (evt.Event === 'QueueEntry') summary.entries.push(evt);
|
||||||
|
}
|
||||||
|
return [...byQueue.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async extensionState(exten: string, context: string): Promise<AmiMessage> {
|
||||||
|
const response = await this.client.sendAction({ Action: 'ExtensionState', Exten: exten, Context: context });
|
||||||
|
if ((response.Response ?? '').toLowerCase() !== 'success') {
|
||||||
|
throw new Error(`ExtensionState falhou: ${response.Message ?? 'motivo desconhecido'}`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deviceState(device: string): Promise<string> {
|
||||||
|
const response = await this.client.sendAction({ Action: 'DeviceState', Device: device });
|
||||||
|
return response.State ?? 'UNKNOWN';
|
||||||
|
}
|
||||||
|
|
||||||
|
async pjsipShowEndpoints(): Promise<PjsipEndpointSummary[]> {
|
||||||
|
const events = await this.client.sendActionCollectEvents({ Action: 'PJSIPShowEndpoints' }, 'EndpointListComplete');
|
||||||
|
return events
|
||||||
|
.filter((e) => e.Event === 'EndpointList')
|
||||||
|
.map((e) => ({ objectName: e.ObjectName ?? '', deviceState: e.DeviceState, contacts: e.Contacts }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async pjsipShowContacts(): Promise<PjsipContactSummary[]> {
|
||||||
|
const events = await this.client.sendActionCollectEvents({ Action: 'PJSIPShowContacts' }, 'ContactListComplete');
|
||||||
|
return events
|
||||||
|
.filter((e) => e.Event === 'ContactList')
|
||||||
|
.map((e) => ({ uri: e.URI, status: e.Status, endpointName: e.EndpointName }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async reload(module?: string): Promise<void> {
|
||||||
|
const action: AmiMessage = { Action: 'Reload' };
|
||||||
|
if (module) action.Module = module;
|
||||||
|
const response = await this.client.sendAction(action);
|
||||||
|
if ((response.Response ?? '').toLowerCase() !== 'success') {
|
||||||
|
throw new Error(`Reload falhou: ${response.Message ?? 'motivo desconhecido'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runCommand(command: string): Promise<string> {
|
||||||
|
return this.client.runCommand(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent(handler: (event: AmiMessage) => void): void {
|
||||||
|
this.client.on('event', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
offEvent(handler: (event: AmiMessage) => void): void {
|
||||||
|
this.client.off('event', handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/telephony/src/index.ts
Normal file
3
packages/telephony/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './ami-client';
|
||||||
|
export * from './telephony-provider.interface';
|
||||||
|
export * from './asterisk-telephony-provider';
|
||||||
74
packages/telephony/src/telephony-provider.interface.ts
Normal file
74
packages/telephony/src/telephony-provider.interface.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { AmiMessage } from './ami-client';
|
||||||
|
|
||||||
|
export interface OriginateParams {
|
||||||
|
channel: string;
|
||||||
|
context?: string;
|
||||||
|
exten?: string;
|
||||||
|
priority?: number | string;
|
||||||
|
application?: string;
|
||||||
|
data?: string;
|
||||||
|
callerId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
variables?: Record<string, string>;
|
||||||
|
async?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueuePauseParams {
|
||||||
|
interface: string;
|
||||||
|
paused: boolean;
|
||||||
|
queue?: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PjsipEndpointSummary {
|
||||||
|
objectName: string;
|
||||||
|
deviceState?: string;
|
||||||
|
contacts?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PjsipContactSummary {
|
||||||
|
uri?: string;
|
||||||
|
status?: string;
|
||||||
|
endpointName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueStatusSummary {
|
||||||
|
queue: string;
|
||||||
|
calls?: string;
|
||||||
|
members: AmiMessage[];
|
||||||
|
entries: AmiMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Camada de abstração de telefonia (agente.md seção 7). Nenhum comando AMI
|
||||||
|
* deve ser chamado diretamente de controllers — sempre por aqui, para que
|
||||||
|
* trocar de Asterisk para outro backend de telefonia no futuro não exija
|
||||||
|
* reescrever a aplicação inteira.
|
||||||
|
*/
|
||||||
|
export interface TelephonyProvider {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
disconnect(): void;
|
||||||
|
isConnected(): boolean;
|
||||||
|
|
||||||
|
originate(params: OriginateParams): Promise<AmiMessage>;
|
||||||
|
hangup(channel: string, cause?: number): Promise<void>;
|
||||||
|
|
||||||
|
queuePause(params: QueuePauseParams): Promise<void>;
|
||||||
|
queueAdd(queue: string, iface: string, opts?: { penalty?: number; memberName?: string }): Promise<void>;
|
||||||
|
queueRemove(queue: string, iface: string): Promise<void>;
|
||||||
|
queueStatus(queue?: string): Promise<QueueStatusSummary[]>;
|
||||||
|
|
||||||
|
extensionState(exten: string, context: string): Promise<AmiMessage>;
|
||||||
|
deviceState(device: string): Promise<string>;
|
||||||
|
|
||||||
|
pjsipShowEndpoints(): Promise<PjsipEndpointSummary[]>;
|
||||||
|
pjsipShowContacts(): Promise<PjsipContactSummary[]>;
|
||||||
|
|
||||||
|
reload(module?: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Executa um comando de CLI — o allowlist é responsabilidade do chamador. */
|
||||||
|
runCommand(command: string): Promise<string>;
|
||||||
|
|
||||||
|
onEvent(handler: (event: AmiMessage) => void): void;
|
||||||
|
offEvent(handler: (event: AmiMessage) => void): void;
|
||||||
|
}
|
||||||
14
packages/telephony/tsconfig.json
Normal file
14
packages/telephony/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"target": "ES2022",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
@@ -153,6 +153,31 @@ importers:
|
|||||||
specifier: ^8.20.0
|
specifier: ^8.20.0
|
||||||
version: 8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)
|
version: 8.68.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)
|
||||||
|
|
||||||
|
apps/asterisk-events:
|
||||||
|
dependencies:
|
||||||
|
'@b2bcall/database':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/database
|
||||||
|
'@b2bcall/telephony':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/telephony
|
||||||
|
ioredis:
|
||||||
|
specifier: ^5.4.2
|
||||||
|
version: 5.11.1(supports-color@8.1.1)
|
||||||
|
pino:
|
||||||
|
specifier: ^9.6.0
|
||||||
|
version: 9.14.0
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^24.0.0
|
||||||
|
version: 24.13.3
|
||||||
|
ts-node:
|
||||||
|
specifier: ^10.9.2
|
||||||
|
version: 10.9.2(@types/node@24.13.3)(typescript@5.9.3)
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.3
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
packages/database:
|
packages/database:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
@@ -184,6 +209,15 @@ importers:
|
|||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/telephony:
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^24.0.0
|
||||||
|
version: 24.13.3
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.3
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@angular-devkit/core@19.2.24':
|
'@angular-devkit/core@19.2.24':
|
||||||
|
|||||||
Reference in New Issue
Block a user