- 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.
179 lines
6.6 KiB
TypeScript
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);
|
|
}
|
|
}
|