/** * Call — the hub entity in the hub model. * * A Call owns N legs and bridges their media. For 2-party calls, RTP packets * from leg A are forwarded to leg B and vice versa. For N>2 party calls, * packets from each leg are forwarded to all other legs (fan-out). * * Transcoding is applied per-leg when codecs differ. */ import { Buffer } from 'node:buffer'; import type { ILeg } from './leg.ts'; import type { TCallState, TCallDirection, ICallStatus } from './types.ts'; import { RtpPortPool } from './rtp-port-pool.ts'; import type { SipLeg } from './sip-leg.ts'; export class Call { readonly id: string; state: TCallState = 'setting-up'; direction: TCallDirection; readonly createdAt: number; callerNumber: string | null = null; calleeNumber: string | null = null; providerUsed: string | null = null; /** All legs in this call. */ private legs = new Map(); /** Codec payload type for the "native" audio in the call (usually the first SIP leg's codec). */ private nativeCodec: number | null = null; /** Port pool reference for cleanup. */ private portPool: RtpPortPool; private log: (msg: string) => void; private onChange: ((call: Call) => void) | null = null; constructor(options: { id: string; direction: TCallDirection; portPool: RtpPortPool; log: (msg: string) => void; onChange?: (call: Call) => void; }) { this.id = options.id; this.direction = options.direction; this.createdAt = Date.now(); this.portPool = options.portPool; this.log = options.log; this.onChange = options.onChange ?? null; } // ------------------------------------------------------------------------- // Leg management // ------------------------------------------------------------------------- /** Add a leg to this call and wire up media forwarding. */ addLeg(leg: ILeg): void { this.legs.set(leg.id, leg); // Wire up RTP forwarding: when this leg receives a packet, forward to all other legs. leg.onRtpReceived = (data: Buffer) => { this.forwardRtp(leg.id, data); }; this.log(`[call:${this.id}] added leg ${leg.id} (${leg.type}), total=${this.legs.size}`); this.updateState(); } /** Remove a leg from this call, tear it down, and release its port. */ removeLeg(legId: string): void { const leg = this.legs.get(legId); if (!leg) return; leg.onRtpReceived = null; leg.teardown(); if (leg.rtpPort) { this.portPool.release(leg.rtpPort); } this.legs.delete(legId); this.log(`[call:${this.id}] removed leg ${legId}, total=${this.legs.size}`); this.updateState(); } getLeg(legId: string): ILeg | null { return this.legs.get(legId) ?? null; } getLegs(): ILeg[] { return [...this.legs.values()]; } getLegByType(type: string): ILeg | null { for (const leg of this.legs.values()) { if (leg.type === type) return leg; } return null; } getLegBySipCallId(sipCallId: string): ILeg | null { for (const leg of this.legs.values()) { if (leg.sipCallId === sipCallId) return leg; } return null; } get legCount(): number { return this.legs.size; } // ------------------------------------------------------------------------- // Media forwarding (the hub) // ------------------------------------------------------------------------- private forwardRtp(fromLegId: string, data: Buffer): void { for (const [id, leg] of this.legs) { if (id === fromLegId) continue; if (leg.state !== 'connected') continue; // For WebRTC legs, sendRtp calls forwardToBrowser which handles transcoding internally. // For SIP legs, forward the raw packet (same codec path) or let the leg handle it. // The Call hub does NOT transcode — that's the leg's responsibility. leg.sendRtp(data); } } // ------------------------------------------------------------------------- // State management // ------------------------------------------------------------------------- private updateState(): void { if (this.state === 'terminated' || this.state === 'terminating') return; const legs = [...this.legs.values()]; if (legs.length === 0) { this.state = 'terminated'; } else if (legs.every((l) => l.state === 'terminated')) { this.state = 'terminated'; } else if (legs.some((l) => l.state === 'connected') && legs.filter((l) => l.state !== 'terminated').length >= 2) { // If a system leg is connected, report voicemail/ivr state for the dashboard. const systemLeg = legs.find((l) => l.type === 'system'); if (systemLeg) { // Keep voicemail/ivr state if already set; otherwise set connected. if (this.state !== 'voicemail' && this.state !== 'ivr') { this.state = 'connected'; } } else { this.state = 'connected'; } } else if (legs.some((l) => l.state === 'ringing')) { this.state = 'ringing'; } else { this.state = 'setting-up'; } this.onChange?.(this); } /** Notify the call that a leg's state has changed. */ notifyLegStateChange(_leg: ILeg): void { this.updateState(); } // ------------------------------------------------------------------------- // Hangup // ------------------------------------------------------------------------- /** Tear down all legs and terminate the call. */ hangup(): void { if (this.state === 'terminated' || this.state === 'terminating') return; this.state = 'terminating'; this.log(`[call:${this.id}] hanging up (${this.legs.size} legs)`); for (const [id, leg] of this.legs) { // Send BYE/CANCEL for SIP legs (system legs have no SIP signaling). if (leg.type === 'sip-device' || leg.type === 'sip-provider') { (leg as SipLeg).sendHangup(); } leg.teardown(); if (leg.rtpPort) { this.portPool.release(leg.rtpPort); } } this.legs.clear(); this.state = 'terminated'; this.onChange?.(this); } /** * Handle a BYE from one leg — tear down the other legs. * Called by CallManager when a SipLeg receives a BYE. */ handleLegTerminated(terminatedLegId: string): void { const terminatedLeg = this.legs.get(terminatedLegId); if (!terminatedLeg) return; // Remove the terminated leg. terminatedLeg.onRtpReceived = null; if (terminatedLeg.rtpPort) { this.portPool.release(terminatedLeg.rtpPort); } this.legs.delete(terminatedLegId); // If this is a 2-party call, hang up the other leg too. if (this.legs.size <= 1) { for (const [id, leg] of this.legs) { // Send BYE/CANCEL for SIP legs (system legs just get torn down). if (leg.type === 'sip-device' || leg.type === 'sip-provider') { (leg as SipLeg).sendHangup(); } leg.teardown(); if (leg.rtpPort) { this.portPool.release(leg.rtpPort); } } this.legs.clear(); this.state = 'terminated'; this.log(`[call:${this.id}] terminated`); this.onChange?.(this); } else { this.log(`[call:${this.id}] leg ${terminatedLegId} removed, ${this.legs.size} remaining`); this.updateState(); } } // ------------------------------------------------------------------------- // Transfer // ------------------------------------------------------------------------- /** * Detach a leg from this call (without tearing it down). * The leg can then be added to another call. */ detachLeg(legId: string): ILeg | null { const leg = this.legs.get(legId); if (!leg) return null; leg.onRtpReceived = null; this.legs.delete(legId); this.log(`[call:${this.id}] detached leg ${legId}`); this.updateState(); return leg; } // ------------------------------------------------------------------------- // Status // ------------------------------------------------------------------------- getStatus(): ICallStatus { return { id: this.id, state: this.state, direction: this.direction, callerNumber: this.callerNumber, calleeNumber: this.calleeNumber, providerUsed: this.providerUsed, createdAt: this.createdAt, duration: Math.floor((Date.now() - this.createdAt) / 1000), legs: [...this.legs.values()].map((l) => l.getStatus()), }; } }