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:
2026-08-27 12:41:41 -03:00
parent a2898fa566
commit 6f3d731581
20 changed files with 1059 additions and 4 deletions

View File

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

View File

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

View File

@@ -1,2 +1,3 @@
export * from './permissions';
export * from './generate-password';
export * from './secret-crypto';

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

View 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"
}
}

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

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

View File

@@ -0,0 +1,3 @@
export * from './ami-client';
export * from './telephony-provider.interface';
export * from './asterisk-telephony-provider';

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

View 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"]
}