517 lines
14 KiB
TypeScript
517 lines
14 KiB
TypeScript
/**
|
|
* Proxy engine bridge — manages the Rust proxy-engine subprocess.
|
|
*
|
|
* The proxy-engine handles ALL SIP protocol mechanics. TypeScript only:
|
|
* - Sends configuration
|
|
* - Receives high-level events (incoming_call, call_ended, etc.)
|
|
* - Sends high-level commands (hangup, make_call, play_audio)
|
|
*
|
|
* No raw SIP ever touches TypeScript.
|
|
*/
|
|
|
|
import path from 'node:path';
|
|
import { RustBridge } from '@push.rocks/smartrust';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Command type map for smartrust
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type TProxyCommands = {
|
|
configure: {
|
|
params: Record<string, unknown>;
|
|
result: { bound: string };
|
|
};
|
|
hangup: {
|
|
params: { call_id: string };
|
|
result: Record<string, never>;
|
|
};
|
|
make_call: {
|
|
params: { number: string; device_id?: string; provider_id?: string };
|
|
result: { call_id: string };
|
|
};
|
|
play_audio: {
|
|
params: { call_id: string; leg_id?: string; file_path: string; codec?: number };
|
|
result: Record<string, never>;
|
|
};
|
|
start_recording: {
|
|
params: { call_id: string; file_path: string; max_duration_ms?: number };
|
|
result: Record<string, never>;
|
|
};
|
|
stop_recording: {
|
|
params: { call_id: string };
|
|
result: { file_path: string; duration_ms: number };
|
|
};
|
|
add_device_leg: {
|
|
params: { call_id: string; device_id: string };
|
|
result: { leg_id: string };
|
|
};
|
|
transfer_leg: {
|
|
params: { source_call_id: string; leg_id: string; target_call_id: string };
|
|
result: Record<string, never>;
|
|
};
|
|
replace_leg: {
|
|
params: { call_id: string; old_leg_id: string; number: string; provider_id?: string };
|
|
result: { new_leg_id: string };
|
|
};
|
|
start_interaction: {
|
|
params: {
|
|
call_id: string;
|
|
leg_id: string;
|
|
prompt_wav: string;
|
|
expected_digits: string;
|
|
timeout_ms: number;
|
|
};
|
|
result: { result: 'digit' | 'timeout' | 'cancelled'; digit?: string };
|
|
};
|
|
add_tool_leg: {
|
|
params: {
|
|
call_id: string;
|
|
tool_type: 'recording' | 'transcription';
|
|
config?: Record<string, unknown>;
|
|
};
|
|
result: { tool_leg_id: string };
|
|
};
|
|
remove_tool_leg: {
|
|
params: { call_id: string; tool_leg_id: string };
|
|
result: Record<string, never>;
|
|
};
|
|
set_leg_metadata: {
|
|
params: { call_id: string; leg_id: string; key: string; value: unknown };
|
|
result: Record<string, never>;
|
|
};
|
|
generate_tts: {
|
|
params: { model: string; voices: string; voice: string; text: string; output: string };
|
|
result: { output: string };
|
|
};
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Event types from Rust
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface IIncomingCallEvent {
|
|
call_id: string;
|
|
from_uri: string;
|
|
to_number: string;
|
|
provider_id: string;
|
|
}
|
|
|
|
export interface IOutboundCallEvent {
|
|
call_id: string;
|
|
from_device: string | null;
|
|
to_number: string;
|
|
}
|
|
|
|
export interface ICallEndedEvent {
|
|
call_id: string;
|
|
reason: string;
|
|
duration: number;
|
|
from_side?: string;
|
|
}
|
|
|
|
export interface IProviderRegisteredEvent {
|
|
provider_id: string;
|
|
registered: boolean;
|
|
public_ip: string | null;
|
|
}
|
|
|
|
export interface IDeviceRegisteredEvent {
|
|
device_id: string;
|
|
display_name: string;
|
|
address: string;
|
|
port: number;
|
|
aor: string;
|
|
expires: number;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bridge singleton
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let bridge: RustBridge<TProxyCommands> | null = null;
|
|
let initialized = false;
|
|
let logFn: ((msg: string) => void) | undefined;
|
|
|
|
function buildLocalPaths(): string[] {
|
|
const root = process.cwd();
|
|
return [
|
|
path.join(root, 'dist_rust', 'proxy-engine'),
|
|
path.join(root, 'rust', 'target', 'release', 'proxy-engine'),
|
|
path.join(root, 'rust', 'target', 'debug', 'proxy-engine'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Initialize the proxy engine — spawn the Rust binary.
|
|
* Call configure() separately to push config and start SIP.
|
|
*/
|
|
export async function initProxyEngine(log?: (msg: string) => void): Promise<boolean> {
|
|
if (initialized && bridge) return true;
|
|
logFn = log;
|
|
|
|
try {
|
|
bridge = new RustBridge<TProxyCommands>({
|
|
binaryName: 'proxy-engine',
|
|
localPaths: buildLocalPaths(),
|
|
});
|
|
|
|
const spawned = await bridge.spawn();
|
|
if (!spawned) {
|
|
log?.('[proxy-engine] failed to spawn binary');
|
|
bridge = null;
|
|
return false;
|
|
}
|
|
|
|
bridge.on('exit', () => {
|
|
logFn?.('[proxy-engine] process exited — will need re-init');
|
|
bridge = null;
|
|
initialized = false;
|
|
});
|
|
|
|
// Forward stderr for debugging.
|
|
bridge.on('stderr', (line: string) => {
|
|
logFn?.(`[proxy-engine:stderr] ${line}`);
|
|
});
|
|
|
|
initialized = true;
|
|
log?.('[proxy-engine] spawned and ready');
|
|
return true;
|
|
} catch (e: any) {
|
|
log?.(`[proxy-engine] init error: ${e.message}`);
|
|
bridge = null;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send the full app config to the proxy engine.
|
|
* This binds the SIP socket, starts provider registrations, etc.
|
|
*/
|
|
export async function configureProxyEngine(config: Record<string, unknown>): Promise<boolean> {
|
|
if (!bridge || !initialized) return false;
|
|
try {
|
|
const result = await bridge.sendCommand('configure', config as any);
|
|
logFn?.(`[proxy-engine] configured, SIP bound on ${(result as any)?.bound || '?'}`);
|
|
return true;
|
|
} catch (e: any) {
|
|
logFn?.(`[proxy-engine] configure error: ${e.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initiate an outbound call via Rust. Returns the call ID or null on failure.
|
|
*/
|
|
export async function makeCall(number: string, deviceId?: string, providerId?: string): Promise<string | null> {
|
|
if (!bridge || !initialized) return null;
|
|
try {
|
|
const result = await bridge.sendCommand('make_call', {
|
|
number,
|
|
device_id: deviceId,
|
|
provider_id: providerId,
|
|
} as any);
|
|
return (result as any)?.call_id || null;
|
|
} catch (e: any) {
|
|
logFn?.(`[proxy-engine] make_call error: ${e?.message || e}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a hangup command.
|
|
*/
|
|
export async function hangupCall(callId: string): Promise<boolean> {
|
|
if (!bridge || !initialized) return false;
|
|
try {
|
|
await bridge.sendCommand('hangup', { call_id: callId } as any);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a WebRTC offer to the proxy engine. Returns the SDP answer.
|
|
*/
|
|
export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp: string } | null> {
|
|
if (!bridge || !initialized) return null;
|
|
try {
|
|
const result = await bridge.sendCommand('webrtc_offer', { session_id: sessionId, sdp } as any);
|
|
return result as any;
|
|
} catch (e: any) {
|
|
logFn?.(`[proxy-engine] webrtc_offer error: ${e?.message || e}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Forward an ICE candidate to the proxy engine.
|
|
*/
|
|
export async function webrtcIce(sessionId: string, candidate: any): Promise<void> {
|
|
if (!bridge || !initialized) return;
|
|
try {
|
|
await bridge.sendCommand('webrtc_ice', {
|
|
session_id: sessionId,
|
|
candidate: candidate?.candidate || candidate,
|
|
sdp_mid: candidate?.sdpMid,
|
|
sdp_mline_index: candidate?.sdpMLineIndex,
|
|
} as any);
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
/**
|
|
* Link a WebRTC session to a SIP call — enables audio bridging.
|
|
* The browser's Opus audio will be transcoded and sent to the provider.
|
|
*/
|
|
export async function webrtcLink(sessionId: string, callId: string, providerMediaAddr: string, providerMediaPort: number, sipPt: number = 9): Promise<boolean> {
|
|
if (!bridge || !initialized) return false;
|
|
try {
|
|
await bridge.sendCommand('webrtc_link', {
|
|
session_id: sessionId,
|
|
call_id: callId,
|
|
provider_media_addr: providerMediaAddr,
|
|
provider_media_port: providerMediaPort,
|
|
sip_pt: sipPt,
|
|
} as any);
|
|
return true;
|
|
} catch (e: any) {
|
|
logFn?.(`[proxy-engine] webrtc_link error: ${e?.message || e}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add an external SIP leg to an existing call (multiparty).
|
|
*/
|
|
export async function addLeg(callId: string, number: string, providerId?: string): Promise<string | null> {
|
|
if (!bridge || !initialized) return null;
|
|
try {
|
|
const result = await bridge.sendCommand('add_leg', {
|
|
call_id: callId,
|
|
number,
|
|
provider_id: providerId,
|
|
} as any);
|
|
return (result as any)?.leg_id || null;
|
|
} catch (e: any) {
|
|
logFn?.(`[proxy-engine] add_leg error: ${e?.message || e}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a leg from a call.
|
|
*/
|
|
export async function removeLeg(callId: string, legId: string): Promise<boolean> {
|
|
if (!bridge || !initialized) return false;
|
|
try {
|
|
await bridge.sendCommand('remove_leg', { call_id: callId, leg_id: legId } as any);
|
|
return true;
|
|
} catch (e: any) {
|
|
logFn?.(`[proxy-engine] remove_leg error: ${e?.message || e}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close a WebRTC session.
|
|
*/
|
|
export async function webrtcClose(sessionId: string): Promise<void> {
|
|
if (!bridge || !initialized) return;
|
|
try {
|
|
await bridge.sendCommand('webrtc_close', { session_id: sessionId } as any);
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Device leg & interaction commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Add a local SIP device to an existing call (mid-call INVITE to desk phone).
|
|
*/
|
|
export async function addDeviceLeg(callId: string, deviceId: string): Promise<string | null> {
|
|
if (!bridge || !initialized) return null;
|
|
try {
|
|
const result = await bridge.sendCommand('add_device_leg', {
|
|
call_id: callId,
|
|
device_id: deviceId,
|
|
} as any);
|
|
return (result as any)?.leg_id || null;
|
|
} catch (e: any) {
|
|
logFn?.(`[proxy-engine] add_device_leg error: ${e?.message || e}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Transfer a leg from one call to another (leg stays connected, switches mixer).
|
|
*/
|
|
export async function transferLeg(
|
|
sourceCallId: string,
|
|
legId: string,
|
|
targetCallId: string,
|
|
): Promise<boolean> {
|
|
if (!bridge || !initialized) return false;
|
|
try {
|
|
await bridge.sendCommand('transfer_leg', {
|
|
source_call_id: sourceCallId,
|
|
leg_id: legId,
|
|
target_call_id: targetCallId,
|
|
} as any);
|
|
return true;
|
|
} catch (e: any) {
|
|
logFn?.(`[proxy-engine] transfer_leg error: ${e?.message || e}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace a leg: terminate the old leg and dial a new number into the same call.
|
|
*/
|
|
export async function replaceLeg(
|
|
callId: string,
|
|
oldLegId: string,
|
|
number: string,
|
|
providerId?: string,
|
|
): Promise<string | null> {
|
|
if (!bridge || !initialized) return null;
|
|
try {
|
|
const result = await bridge.sendCommand('replace_leg', {
|
|
call_id: callId,
|
|
old_leg_id: oldLegId,
|
|
number,
|
|
provider_id: providerId,
|
|
} as any);
|
|
return (result as any)?.new_leg_id || null;
|
|
} catch (e: any) {
|
|
logFn?.(`[proxy-engine] replace_leg error: ${e?.message || e}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start an interaction on a specific leg — isolate it, play a prompt, collect DTMF.
|
|
* Blocks until the interaction completes (digit pressed, timeout, or cancelled).
|
|
*/
|
|
export async function startInteraction(
|
|
callId: string,
|
|
legId: string,
|
|
promptWav: string,
|
|
expectedDigits: string,
|
|
timeoutMs: number,
|
|
): Promise<{ result: 'digit' | 'timeout' | 'cancelled'; digit?: string } | null> {
|
|
if (!bridge || !initialized) return null;
|
|
try {
|
|
const result = await bridge.sendCommand('start_interaction', {
|
|
call_id: callId,
|
|
leg_id: legId,
|
|
prompt_wav: promptWav,
|
|
expected_digits: expectedDigits,
|
|
timeout_ms: timeoutMs,
|
|
} as any);
|
|
return result as any;
|
|
} catch (e: any) {
|
|
logFn?.(`[proxy-engine] start_interaction error: ${e?.message || e}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a tool leg (recording or transcription) to a call.
|
|
* Tool legs receive per-source unmerged audio from all participants.
|
|
*/
|
|
export async function addToolLeg(
|
|
callId: string,
|
|
toolType: 'recording' | 'transcription',
|
|
config?: Record<string, unknown>,
|
|
): Promise<string | null> {
|
|
if (!bridge || !initialized) return null;
|
|
try {
|
|
const result = await bridge.sendCommand('add_tool_leg', {
|
|
call_id: callId,
|
|
tool_type: toolType,
|
|
config,
|
|
} as any);
|
|
return (result as any)?.tool_leg_id || null;
|
|
} catch (e: any) {
|
|
logFn?.(`[proxy-engine] add_tool_leg error: ${e?.message || e}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a tool leg from a call. Triggers finalization (WAV files, metadata).
|
|
*/
|
|
export async function removeToolLeg(callId: string, toolLegId: string): Promise<boolean> {
|
|
if (!bridge || !initialized) return false;
|
|
try {
|
|
await bridge.sendCommand('remove_tool_leg', {
|
|
call_id: callId,
|
|
tool_leg_id: toolLegId,
|
|
} as any);
|
|
return true;
|
|
} catch (e: any) {
|
|
logFn?.(`[proxy-engine] remove_tool_leg error: ${e?.message || e}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set a metadata key-value pair on a leg.
|
|
*/
|
|
export async function setLegMetadata(
|
|
callId: string,
|
|
legId: string,
|
|
key: string,
|
|
value: unknown,
|
|
): Promise<boolean> {
|
|
if (!bridge || !initialized) return false;
|
|
try {
|
|
await bridge.sendCommand('set_leg_metadata', {
|
|
call_id: callId,
|
|
leg_id: legId,
|
|
key,
|
|
value,
|
|
} as any);
|
|
return true;
|
|
} catch (e: any) {
|
|
logFn?.(`[proxy-engine] set_leg_metadata error: ${e?.message || e}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subscribe to an event from the proxy engine.
|
|
* Event names: incoming_call, outbound_device_call, call_ringing,
|
|
* call_answered, call_ended, provider_registered, device_registered,
|
|
* dtmf_digit, recording_done, tool_recording_done, tool_transcription_done,
|
|
* leg_added, leg_removed, sip_unhandled
|
|
*/
|
|
export function onProxyEvent(event: string, handler: (data: any) => void): void {
|
|
if (!bridge) throw new Error('proxy engine not initialized');
|
|
bridge.on(`management:${event}`, handler);
|
|
}
|
|
|
|
/** Check if the proxy engine is ready. */
|
|
export function isProxyReady(): boolean {
|
|
return initialized && bridge !== null;
|
|
}
|
|
|
|
/** Send an arbitrary command to the proxy engine bridge. */
|
|
export async function sendProxyCommand<K extends keyof TProxyCommands>(
|
|
method: K,
|
|
params: TProxyCommands[K]['params'],
|
|
): Promise<TProxyCommands[K]['result']> {
|
|
if (!bridge || !initialized) throw new Error('proxy engine not initialized');
|
|
return bridge.sendCommand(method as string, params as any) as any;
|
|
}
|
|
|
|
/** Shut down the proxy engine. */
|
|
export function shutdownProxyEngine(): void {
|
|
if (bridge) {
|
|
try { bridge.kill(); } catch { /* ignore */ }
|
|
bridge = null;
|
|
initialized = false;
|
|
}
|
|
}
|