From 6f3d731581ef2722bc591817924415e5457d6d77 Mon Sep 17 00:00:00 2001 From: B2BCall Bootstrap Date: Thu, 27 Aug 2026 12:41:41 -0300 Subject: [PATCH] feat: add telephony layer and asterisk-events worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .env.example | 6 +- TODO.md | 18 +- apps/asterisk-events/package.json | 22 ++ apps/asterisk-events/src/logger.ts | 7 + apps/asterisk-events/src/main.ts | 83 ++++++ apps/asterisk-events/src/normalize.ts | 61 ++++ apps/asterisk-events/tsconfig.json | 14 + docker-compose.yml | 38 +++ .../docker/asterisk-events.Dockerfile | 37 +++ .../migration.sql | 70 +++++ packages/database/prisma/schema.prisma | 81 ++++++ packages/shared/src/index.ts | 1 + packages/shared/src/secret-crypto.ts | 37 +++ packages/telephony/package.json | 15 + packages/telephony/src/ami-client.ts | 270 ++++++++++++++++++ .../src/asterisk-telephony-provider.ts | 178 ++++++++++++ packages/telephony/src/index.ts | 3 + .../src/telephony-provider.interface.ts | 74 +++++ packages/telephony/tsconfig.json | 14 + pnpm-lock.yaml | 34 +++ 20 files changed, 1059 insertions(+), 4 deletions(-) create mode 100644 apps/asterisk-events/package.json create mode 100644 apps/asterisk-events/src/logger.ts create mode 100644 apps/asterisk-events/src/main.ts create mode 100644 apps/asterisk-events/src/normalize.ts create mode 100644 apps/asterisk-events/tsconfig.json create mode 100644 infrastructure/docker/asterisk-events.Dockerfile create mode 100644 packages/database/prisma/migrations/20260827153251_add_trunks_extensions_extension_states/migration.sql create mode 100644 packages/shared/src/secret-crypto.ts create mode 100644 packages/telephony/package.json create mode 100644 packages/telephony/src/ami-client.ts create mode 100644 packages/telephony/src/asterisk-telephony-provider.ts create mode 100644 packages/telephony/src/index.ts create mode 100644 packages/telephony/src/telephony-provider.interface.ts create mode 100644 packages/telephony/tsconfig.json diff --git a/.env.example b/.env.example index a0fffa4..ff4921c 100644 --- a/.env.example +++ b/.env.example @@ -60,7 +60,11 @@ RATE_LIMIT_LOGIN_MAX=5 RATE_LIMIT_LOGIN_WINDOW_SECONDS=60 # --- 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_USERNAME=b2bcall_ami AMI_SECRET=CHANGE_ME_STRONG_PASSWORD diff --git a/TODO.md b/TODO.md index 1757a03..8993baa 100644 --- a/TODO.md +++ b/TODO.md @@ -92,11 +92,23 @@ mestre original (`agente.md`, seções 90-93). externo — trocar MailerService por nodemailer quando houver ## Fase 4 — Telefonia (camada de aplicação) -- [ ] packages/telephony: TelephonyProvider + AsteriskTelephonyProvider -- [ ] apps/asterisk-events (AMI listener, normalização, persistência, pub/sub) +- [x] packages/telephony: cliente AMI próprio (TCP raw, sem dependência de + 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 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) - [ ] Administração do Asterisk (abas: geral, pjsip, rtp, filas, cdr, cel, logs, ami, ari, modules, diagnóstico com allowlist de comandos) diff --git a/apps/asterisk-events/package.json b/apps/asterisk-events/package.json new file mode 100644 index 0000000..e9021e7 --- /dev/null +++ b/apps/asterisk-events/package.json @@ -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" + } +} diff --git a/apps/asterisk-events/src/logger.ts b/apps/asterisk-events/src/logger.ts new file mode 100644 index 0000000..3da0c65 --- /dev/null +++ b/apps/asterisk-events/src/logger.ts @@ -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' }, +}); diff --git a/apps/asterisk-events/src/main.ts b/apps/asterisk-events/src/main.ts new file mode 100644 index 0000000..d0881b0 --- /dev/null +++ b/apps/asterisk-events/src/main.ts @@ -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); +}); diff --git a/apps/asterisk-events/src/normalize.ts b/apps/asterisk-events/src/normalize.ts new file mode 100644 index 0000000..dde6a30 --- /dev/null +++ b/apps/asterisk-events/src/normalize.ts @@ -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', +]); diff --git a/apps/asterisk-events/tsconfig.json b/apps/asterisk-events/tsconfig.json new file mode 100644 index 0000000..5b8a1cf --- /dev/null +++ b/apps/asterisk-events/tsconfig.json @@ -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"] +} diff --git a/docker-compose.yml b/docker-compose.yml index 3d72da1..f50f3da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,6 +131,10 @@ services: restart: unless-stopped networks: - 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: postgres: condition: service_healthy @@ -158,3 +162,37 @@ services: resources: limits: 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 diff --git a/infrastructure/docker/asterisk-events.Dockerfile b/infrastructure/docker/asterisk-events.Dockerfile new file mode 100644 index 0000000..40528e2 --- /dev/null +++ b/infrastructure/docker/asterisk-events.Dockerfile @@ -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"] diff --git a/packages/database/prisma/migrations/20260827153251_add_trunks_extensions_extension_states/migration.sql b/packages/database/prisma/migrations/20260827153251_add_trunks_extensions_extension_states/migration.sql new file mode 100644 index 0000000..4f3f834 --- /dev/null +++ b/packages/database/prisma/migrations/20260827153251_add_trunks_extensions_extension_states/migration.sql @@ -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"); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index a4b62fe..f2cb8e0 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -131,3 +131,84 @@ model AuditLog { @@index([createdAt]) @@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") +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8bb93a9..11e3df4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,3 @@ export * from './permissions'; export * from './generate-password'; +export * from './secret-crypto'; diff --git a/packages/shared/src/secret-crypto.ts b/packages/shared/src/secret-crypto.ts new file mode 100644 index 0000000..d520230 --- /dev/null +++ b/packages/shared/src/secret-crypto.ts @@ -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'); +} diff --git a/packages/telephony/package.json b/packages/telephony/package.json new file mode 100644 index 0000000..4a81752 --- /dev/null +++ b/packages/telephony/package.json @@ -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" + } +} diff --git a/packages/telephony/src/ami-client.ts b/packages/telephony/src/ami-client.ts new file mode 100644 index 0000000..e9c2cb1 --- /dev/null +++ b/packages/telephony/src/ami-client.ts @@ -0,0 +1,270 @@ +import { EventEmitter } from 'node:events'; +import { Socket, createConnection } from 'node:net'; +import { randomUUID } from 'node:crypto'; + +export type AmiMessage = Record; + +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 { + await new Promise((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 { + 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 { + if (!this.socket) throw new Error('AMI não conectado.'); + const actionId = action.ActionID ?? randomUUID(); + const payload = { ...action, ActionID: actionId }; + + return new Promise((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 { + if (!this.socket) throw new Error('AMI não conectado.'); + const actionId = action.ActionID ?? randomUUID(); + const payload = { ...action, ActionID: actionId }; + + const eventsPromise = new Promise((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((_, 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 { + 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'); + } +} diff --git a/packages/telephony/src/asterisk-telephony-provider.ts b/packages/telephony/src/asterisk-telephony-provider.ts new file mode 100644 index 0000000..8bfbac8 --- /dev/null +++ b/packages/telephony/src/asterisk-telephony-provider.ts @@ -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 { + return this.client.connect(); + } + + disconnect(): void { + this.client.disconnect(); + } + + isConnected(): boolean { + return this.client.isConnected(); + } + + async originate(params: OriginateParams): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const action: AmiMessage = { Action: 'QueueStatus' }; + if (queue) action.Queue = queue; + const events = await this.client.sendActionCollectEvents(action, 'StatusComplete'); + + const byQueue = new Map(); + 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 { + 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 { + const response = await this.client.sendAction({ Action: 'DeviceState', Device: device }); + return response.State ?? 'UNKNOWN'; + } + + async pjsipShowEndpoints(): Promise { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/packages/telephony/src/index.ts b/packages/telephony/src/index.ts new file mode 100644 index 0000000..29aaf51 --- /dev/null +++ b/packages/telephony/src/index.ts @@ -0,0 +1,3 @@ +export * from './ami-client'; +export * from './telephony-provider.interface'; +export * from './asterisk-telephony-provider'; diff --git a/packages/telephony/src/telephony-provider.interface.ts b/packages/telephony/src/telephony-provider.interface.ts new file mode 100644 index 0000000..b4f9921 --- /dev/null +++ b/packages/telephony/src/telephony-provider.interface.ts @@ -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; + 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; + disconnect(): void; + isConnected(): boolean; + + originate(params: OriginateParams): Promise; + hangup(channel: string, cause?: number): Promise; + + queuePause(params: QueuePauseParams): Promise; + queueAdd(queue: string, iface: string, opts?: { penalty?: number; memberName?: string }): Promise; + queueRemove(queue: string, iface: string): Promise; + queueStatus(queue?: string): Promise; + + extensionState(exten: string, context: string): Promise; + deviceState(device: string): Promise; + + pjsipShowEndpoints(): Promise; + pjsipShowContacts(): Promise; + + reload(module?: string): Promise; + + /** Executa um comando de CLI — o allowlist é responsabilidade do chamador. */ + runCommand(command: string): Promise; + + onEvent(handler: (event: AmiMessage) => void): void; + offEvent(handler: (event: AmiMessage) => void): void; +} diff --git a/packages/telephony/tsconfig.json b/packages/telephony/tsconfig.json new file mode 100644 index 0000000..3a7ea38 --- /dev/null +++ b/packages/telephony/tsconfig.json @@ -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"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad42e17..510c630 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,31 @@ importers: 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) + 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: dependencies: '@prisma/client': @@ -184,6 +209,15 @@ importers: specifier: ^5.7.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: '@angular-devkit/core@19.2.24':