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:
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user