Files
b2bcall/packages/telephony/src/asterisk-telephony-provider.ts
B2BCall Bootstrap 6f3d731581 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.
2026-08-27 12:41:41 -03:00

179 lines
6.6 KiB
TypeScript

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