@telnyx/react-native-voice-sdk
Version:
Telnyx React Native Voice SDK - A complete WebRTC voice calling solution
1,088 lines (952 loc) • 34.7 kB
text/typescript
import { EventEmitter } from 'eventemitter3';
import log from 'loglevel';
import uuid from 'uuid-random';
import type { CallOptions } from './call-options';
import { Connection } from './connection';
import type { AnswerEvent, ByeEvent, RingingEvent } from './messages/call';
import {
createAnswerMessage,
createCandidateMessage,
createDTMFRequest,
createEndOfCandidatesMessage,
createHangupRequest,
createInviteMessage,
createModifyCallRequest,
isAnswerEvent,
isByeEvent,
isDTMFResponse,
isInviteACKMessage,
isModifyCallAnswer,
isRingingEvent,
} from './messages/call';
import { createAttachMessage } from './messages/attach';
import { Peer } from './peer';
import type { TrickleIceCandidate } from './peer';
import {
prepareTrickleSdp,
normalizeRemoteCandidateString,
cleanCandidateString,
} from './sdp-utils';
import { WebRTCReporter } from './webrtc-reporter';
import { CallReportCollector } from './call-report-collector';
import type { CallReportConfig, CallReportSummary } from './call-report-models';
import { DEFAULT_CALL_REPORT_CONFIG } from './call-report-models';
import { CALL_REPORT_ID, VOICE_SDK_ID } from './global';
import { PROD_HOST, SDK_VERSION } from './env';
type CallEvents = {
'telnyx.call.state': (call: Call, state: CallState) => void;
};
/**
* Possible states a call can be in.
* - 'new': Initial state when a call object is first created, before any signaling.
* - 'ringing': The call is ringing on the remote side, waiting for the callee to answer.
* - 'connecting': The call is being set up (SDP negotiation / ICE gathering in progress).
* - 'active': The call is fully established with media flowing between peers.
* - 'held': The call has been placed on hold by either party.
* - 'dropped': The call lost network connectivity but is NOT terminated.
* A dropped call remains tracked because it may be reattached when
* the network recovers via the reconnection flow.
* - 'ended': The call is fully terminated and will be removed from tracking.
*/
export type CallState = 'new' | 'ringing' | 'connecting' | 'active' | 'ended' | 'held' | 'dropped';
type CallConstructorParams = {
connection: Connection;
options: CallOptions;
sessionId: string;
direction: CallDirection;
telnyxSessionId: string | null;
telnyxLegId: string | null;
telnyxCallControlId?: string | null;
callId: string | null;
callState?: CallState;
debug?: boolean;
callReportConfig?: CallReportConfig;
};
export type CallDirection = 'inbound' | 'outbound';
export type CreateInboundCall = {
connection: Connection;
options: CallOptions;
sessionId: string;
telnyxSessionId: string;
telnyxLegId: string;
telnyxCallControlId?: string | null;
remoteSDP: string;
callId: string;
inviteCustomHeaders?: { name: string; value: string }[] | null;
initialState?: CallState;
debug?: boolean;
callReportConfig?: CallReportConfig;
};
// TODO persist customHeaders and clientState
export class Call extends EventEmitter<CallEvents> {
public direction: CallDirection;
public callId: string;
public telnyxSessionId: string | null;
public telnyxCallControlId: string | null;
public telnyxLegId: string | null;
public state: CallState;
/**
* Custom headers received from the WebRTC INVITE message.
* These headers are passed during call initiation and can contain application-specific information.
* Format should be [{"name": "X-Header-Name", "value": "Value"}] where header names must start with "X-".
*/
public inviteCustomHeaders: { name: string; value: string }[] | null = null;
/**
* Custom headers received from the WebRTC ANSWER message.
* These headers are passed during call acceptance and can contain application-specific information.
* Format should be [{"name": "X-Header-Name", "value": "Value"}] where header names must start with "X-".
*/
public answerCustomHeaders: { name: string; value: string }[] | null = null;
static async createInboundCall({
connection,
options,
remoteSDP,
sessionId,
telnyxLegId,
telnyxSessionId,
telnyxCallControlId,
callId,
inviteCustomHeaders = null,
initialState = 'ringing',
debug = false,
callReportConfig,
}: CreateInboundCall) {
const call = new Call({
connection,
options,
sessionId,
direction: 'inbound',
telnyxLegId,
telnyxSessionId,
telnyxCallControlId,
callId,
callState: initialState,
debug,
callReportConfig,
});
// Store the custom headers from the INVITE message
call.inviteCustomHeaders = inviteCustomHeaders;
// Check if the connection has a reference to the client with stored push notification CallKit UUID
// The client should store the UUID when CallKitHandler processes the push notification
const connectionClient = (connection as any)._client;
if (connectionClient && typeof connectionClient.getPushNotificationCallKitUUID === 'function') {
const storedCallKitUUID = connectionClient.getPushNotificationCallKitUUID();
if (storedCallKitUUID) {
log.debug('[Call] Automatically assigning stored CallKit UUID to new inbound call:', {
callId,
storedCallKitUUID,
});
// Set multiple CallKit UUID properties on the call object during creation
(call as any)._callKitUUID = storedCallKitUUID;
(call as any)._isPushNotificationCall = true;
(call as any)._pushCallKitUUID = storedCallKitUUID;
(call as any)._originalCallKitUUID = storedCallKitUUID;
// Make these properties non-configurable to prevent accidental deletion
try {
Object.defineProperty(call, '_callKitUUID', {
value: storedCallKitUUID,
writable: false,
enumerable: false,
configurable: false,
});
Object.defineProperty(call, '_isPushNotificationCall', {
value: true,
writable: false,
enumerable: false,
configurable: false,
});
} catch (error) {
log.warn('[Call] Could not make UUID properties non-configurable:', error);
}
// Clear the stored UUID since we've used it
connectionClient.setPushNotificationCallKitUUID(null);
log.debug('[Call] CallKit UUID properties set during call creation:', {
_callKitUUID: (call as any)._callKitUUID,
_isPushNotificationCall: (call as any)._isPushNotificationCall,
_pushCallKitUUID: (call as any)._pushCallKitUUID,
_originalCallKitUUID: (call as any)._originalCallKitUUID,
});
}
}
call.peer = new Peer(options);
call.setupTrickleIceCallbacks();
call.peer.createPeerConnection();
call.peer.setRemoteDescription({ type: 'offer', sdp: remoteSDP });
// Initialize WebRTC reporter if debug mode is enabled
if (debug) {
call.initializeReporter();
}
call.setState(initialState);
return call;
}
private connection: Connection;
private options: CallOptions;
private peer: Peer | null;
private sessionId: string;
private reporter: WebRTCReporter | null = null;
private debugEnabled: boolean = false;
private callReportCollector: CallReportCollector | null = null;
private callReportConfig: CallReportConfig;
private callReportPostingStarted = false;
private callStartTimestamp: string;
constructor({
connection,
options,
sessionId,
direction,
callId,
telnyxLegId,
telnyxSessionId,
telnyxCallControlId = null,
callState = 'new',
debug = false,
callReportConfig,
}: CallConstructorParams) {
super();
this.connection = connection;
this.options = options;
this.sessionId = sessionId;
this.callId = callId ?? uuid();
this.direction = direction;
this.state = callState;
this.telnyxLegId = telnyxLegId;
this.telnyxSessionId = telnyxSessionId;
this.telnyxCallControlId = telnyxCallControlId;
this.peer = null;
this.debugEnabled = debug;
this.callReportConfig = callReportConfig ?? DEFAULT_CALL_REPORT_CONFIG;
this.callStartTimestamp = new Date().toISOString();
// Initialize call report collector if enabled
if (this.callReportConfig.enableCallReports) {
this.callReportCollector = new CallReportCollector(this.callReportConfig);
this.callReportCollector.log('info', 'Call started', {
callId: this.callId,
direction: this.direction,
});
}
this.connection.addListener('telnyx.socket.message', this.onSocketMessage);
}
/**
* Initialize the WebRTC reporter for debug stats collection
*/
private initializeReporter(): void {
if (!this.peer || !this.debugEnabled) {
return;
}
const peerConnection = this.peer.getPeerConnection();
if (!peerConnection) {
log.warn('[Call] Cannot initialize reporter: no peer connection');
return;
}
log.debug('[Call] Initializing WebRTC reporter for debug stats');
this.reporter = new WebRTCReporter({
connection: this.connection,
peerId: this.callId,
connectionId: this.telnyxLegId || this.callId,
peerConnection: peerConnection as any,
iceServers: this.peer.getIceServers() as any,
});
// Set the reporter on the peer for event forwarding
this.peer.setReporter(this.reporter);
// Start stats collection
this.reporter.startStats();
}
/**
* Start debug stats collection manually
* This can be called after the call is established if debug mode was not enabled initially
*/
public startDebugStats(): void {
if (this.reporter) {
log.debug('[Call] Debug stats already started');
return;
}
if (!this.peer) {
log.warn('[Call] Cannot start debug stats: no peer connection');
return;
}
this.debugEnabled = true;
this.initializeReporter();
}
/**
* Stop debug stats collection
*/
public stopDebugStats(): void {
if (this.reporter) {
log.debug('[Call] Stopping debug stats collection');
this.reporter.stopStats();
this.reporter = null;
}
if (this.peer && typeof this.peer.setReporter === 'function') {
this.peer.setReporter(null);
}
}
/**
* Check if debug stats collection is active
*/
public isDebugStatsActive(): boolean {
return this.reporter?.isActive() ?? false;
}
/**
* Get the debug stats ID
*/
public getDebugStatsId(): string | null {
return this.reporter?.getDebugStatsId() ?? null;
}
public get remoteStream() {
return this.options.remoteStream;
}
public get localStream() {
return this.options.localStream;
}
/**
* Accept an incoming call
* This method will attach the local audio stream, create an answer,
* and send the answer message to the Telnyx platform.
* It will also set the call state to 'active'.
* @param customHeaders Optional custom headers to include with the answer
* @throws {Error} If the peer connection is not created
* @returns {Promise<void>} A promise that resolves when the call is accepted
*/
public answer = async (customHeaders?: { name: string; value: string }[]) => {
if (!this.peer) {
throw new Error('[Call] Peer is not created');
}
// Set state to connecting when starting the answer process
log.debug('[Call] Setting state to connecting before answering');
this.setState('connecting');
await this.peer
.attachLocalStream({ audio: true, video: false })
.then((peer) => peer.createAnswer());
if (!this.isTrickleIceEnabled()) {
await this.peer.waitForIceGatheringComplete();
}
await this.connection.sendAndWait(
createAnswerMessage({
callId: this.callId,
dialogParams: {},
sdp: this.getLocalSdpForSignaling(),
telnyxLegId: this.telnyxLegId!,
telnyxSessionId: this.telnyxSessionId!,
sessionId: this.sessionId,
customHeaders,
})
);
if (this.isTrickleIceEnabled()) {
this.peer.flushPendingLocalCandidates();
}
this.setState('active');
};
/**
* Accept an incoming call for attachment
* This method will attach the local audio stream, create an answer,
* and send the answer message to the Telnyx platform for call reattachment.
* It will also set the call state to 'active'.
* @throws {Error} If the peer connection is not created
* @returns {Promise<void>} A promise that resolves when the call is attached
*/
public answerAttach = async () => {
log.debug('[Call] Starting answerAttach process');
if (!this.peer) {
log.error('[Call] No peer connection available for answerAttach');
throw new Error('[Call] Peer is not created');
}
try {
log.debug('[Call] Attaching local stream and creating answer for reattachment');
await this.peer.attachLocalStream({ audio: true, video: false }).then((peer) => {
log.debug('[Call] Local stream attached, creating answer');
return peer.createAnswer();
});
if (!this.isTrickleIceEnabled()) {
log.debug('[Call] Answer created, waiting for ICE gathering');
await this.peer.waitForIceGatheringComplete();
}
log.debug('[Call] Preparing answer attach message');
if (!this.peer.localDescription?.sdp) {
log.error('[Call] No local SDP available for answer message');
throw new Error('[Call] Local SDP not available');
}
const attachMessage = createAttachMessage({
callId: this.callId,
sessionId: this.sessionId,
sdp: this.getLocalSdpForSignaling(),
customHeaders: this.inviteCustomHeaders || [],
destinationNumber: this.options.destinationNumber || '',
callerIdName: this.options.callerIdName || '',
callerIdNumber: this.options.callerIdNumber || '',
clientState: this.options.clientState || '',
userVariables: [], // Not available in CallOptions, using empty array
});
log.debug('[Call] Sending attach message for reattachment:', {
callId: this.callId,
sessionId: this.sessionId,
hasCustomHeaders: !!(this.inviteCustomHeaders && this.inviteCustomHeaders.length > 0),
});
// Send attach message for call reattachment (critical for WebRTC media flow)
await this.connection.sendAndWait(attachMessage);
if (this.isTrickleIceEnabled()) {
this.peer.flushPendingLocalCandidates();
}
log.debug('[Call] Attach message sent successfully, setting call to active');
this.setState('active');
log.debug('[Call] answerAttach completed successfully');
} catch (error) {
log.error('[Call] answerAttach failed:', error);
throw error;
}
};
/**
* Hang up the call
* This method will send a hangup request to the Telnyx platform,
* close the peer connection, and set the call state to 'ended'.
* @param customHeaders Optional custom headers to include with the hangup request
*/
public hangup = (customHeaders?: { name: string; value: string }[]) => {
// Log local hangup to call report
this.callReportCollector?.log('info', 'Call ended', {
cause: 'USER_BUSY',
causeCode: 17,
});
// Stop debug stats collection before ending the call
this.stopDebugStats();
this.connection.send(
createHangupRequest({
callId: this.callId,
telnyxLegId: this.telnyxLegId!,
telnyxSessionId: this.telnyxSessionId!,
cause: 'USER_BUSY',
causeCode: 17,
sessionId: this.sessionId,
customHeaders,
})
);
this.closePeerAfterCallReport('done');
this.setState('ended');
};
/**
* Hold the call
* This method will send a hold request to the Telnyx platform,
* and set the call state to 'held'.
* @throws {Error} If the hold action fails or if the response is invalid
* @return {Promise<void>} A promise that resolves when the call is held
*/
public hold = async () => {
const holdRequest = createModifyCallRequest({
action: 'hold',
callId: this.callId,
sessionId: this.sessionId,
});
const result = await this.connection.sendAndWait(holdRequest);
if (!isModifyCallAnswer(result)) {
throw new Error(`[Call] Invalid hold response received: ${JSON.stringify(result)}`);
}
if (result.result.holdState !== 'held') {
throw new Error(`[Call] Hold action failed: ${JSON.stringify(result)}`);
}
this.setState('held');
};
/**
* Unhold the call
* This method will send an unhold request to the Telnyx platform,
* and set the call state to 'active'.
* @throws {Error} If the unhold action fails or if the response is invalid
* @return {Promise<void>} A promise that resolves when the call is unheld
*/
public unhold = async () => {
const unholdRequest = createModifyCallRequest({
action: 'unhold',
sessionId: this.sessionId,
callId: this.callId,
});
const result = await this.connection.sendAndWait(unholdRequest);
if (!isModifyCallAnswer(result)) {
throw new Error(`[Call] Invalid hold response received: ${JSON.stringify(result)}`);
}
if (result.result.holdState !== 'held') {
throw new Error(`[Call] Hold action failed: ${JSON.stringify(result)}`);
}
this.setState('active');
};
/**
* Send DTMF tones on this call as a Verto `telnyx_rtc.info` message.
*
* @param digits One or more DTMF characters. Valid characters are `0-9`, `A-D`,
* `*`, and `#`; anything else is silently dropped by the platform.
* @returns A promise that resolves with the gateway ack object (`{ sessid, message? }`)
* once the INFO request has been acknowledged.
* @throws If the socket returns an error frame or a malformed response (no `result.sessid`).
* @example
* ```typescript
* await call.dtmf('1234#');
* ```
*/
public dtmf = async (digits: string) => {
const DTMFRequest = createDTMFRequest({
digits,
sessionId: this.sessionId,
callId: this.callId,
callerIdName: this.options.callerIdName,
callerIdNumber: this.options.callerIdNumber,
clientState: this.options.clientState,
customHeaders: this.options.customHeaders,
destinationNumber: this.options.destinationNumber,
remoteCallerIdName: this.options.remoteCallerIdName,
});
const response = await this.connection.sendAndWait(DTMFRequest);
if (!isDTMFResponse(response)) {
throw new Error(`[Call] Invalid DTMF response received: ${JSON.stringify(response)}`);
}
return response.result;
};
/**
* Mute the local audio stream
* This method will set the local audio stream state to false,
* effectively muting the audio.
* @throws {Error} If the peer connection or local stream is not set
* @returns {void}
* @example
* ```typescript
* call.mute();
* console.log('Call muted');
* ```
* @see {@link Call.unmute} for unmuting the call.
* @see {@link Call.deaf} for deafening the call.
* @see {@link Call.undeaf} for undeafening the call.
*
*/
public mute = () => {
if (!this.peer) {
throw new Error('[Call] Peer not created');
}
if (!this.options.localStream) {
throw new Error('[Call] Local stream not set');
}
this.peer.setMediaStreamState(this.options.localStream, false);
};
/**
* Unmute the local audio stream
* This method will set the local audio stream state to true,
* effectively unmuting the audio.
* @throws {Error} If the peer connection or local stream is not set
* @returns {void}
* @example
* ```typescript
* call.unmute();
* console.log('Call unmuted');
* ```
* @see {@link Call.mute} for muting the call.
* @see {@link Call.deaf} for deafening the call.
* @see {@link Call.undeaf} for undeafening the call.
*/
public unmute = () => {
if (!this.peer) {
throw new Error('[Call] Peer not created');
}
if (!this.options.localStream) {
throw new Error('[Call] Local stream not set');
}
this.peer.setMediaStreamState(this.options.localStream, true);
};
/**
* Deafen the remote audio stream
* This method will set the remote audio stream state to false,
* effectively deafening the audio.
* @throws {Error} If the peer connection or remote stream is not set
* @returns {void}
* @example
* ```typescript
* call.deaf();
* console.log('Call deafened');
* ```
* @see {@link Call.undeaf} for undeafening the call.
* @see {@link Call.mute} for muting the call.
* @see {@link Call.unmute} for unmuting the call.
*
*/
public deaf = () => {
if (!this.peer) {
throw new Error('[Call] Peer not created');
}
if (!this.options.remoteStream) {
throw new Error('[Call] Remote stream not set');
}
this.peer.setMediaStreamState(this.options.remoteStream, false);
};
/**
* Undeafen the remote audio stream
* This method will set the remote audio stream state to true,
* effectively undeafening the audio.
* @throws {Error} If the peer connection or remote stream is not set
* @returns {void}
* @example
* ```typescript
* call.undeaf();
* console.log('Call undeafened');
* ```
* @see {@link Call.deaf} for deafening the call.
* @see {@link Call.mute} for muting the call.
* @see {@link Call.unmute} for unmuting the call.
*
*/
public undeaf = () => {
if (!this.peer) {
throw new Error('[Call] Peer not created');
}
if (!this.options.remoteStream) {
throw new Error('[Call] Remote stream not set');
}
this.peer.setMediaStreamState(this.options.remoteStream, true);
};
/**
* Handle early media/ringback from media events with SDP
* This method sets up early media playback for ringback tones or announcements
* received before the call is answered.
* @param sdp - The SDP from the media event for early media setup
*/
public handleEarlyMedia = (sdp: string) => {
log.debug('[Call] Handling early media with SDP');
if (!this.peer) {
log.warn('[Call] No peer connection available for early media setup');
return;
}
try {
// For outbound calls, the media SDP is an answer to our local offer
// For inbound calls, the media SDP would be an offer (but inbound calls typically get SDP in invite)
const sdpType = this.direction === 'outbound' ? 'answer' : 'offer';
log.debug(`[Call] Setting early media SDP as ${sdpType} for ${this.direction} call`);
this.peer.setRemoteDescription({ type: sdpType, sdp });
log.debug('[Call] Early media SDP set successfully');
} catch (error) {
log.error('[Call] Failed to set early media SDP:', error);
}
};
/**
* Handle media update events (audio/video enable/disable)
* @param mediaUpdate - Object containing audio/video states and target
*/
public handleMediaUpdate = (mediaUpdate: {
audio?: boolean;
video?: boolean;
target?: 'local' | 'remote';
}) => {
log.debug('[Call] Handling media update:', mediaUpdate);
const { audio, video, target = 'remote' } = mediaUpdate;
// Handle audio changes
if (audio !== undefined) {
try {
if (target === 'local' && this.options.localStream) {
this.peer?.setMediaStreamState(this.options.localStream, audio);
log.debug(`[Call] ${audio ? 'Enabled' : 'Disabled'} local audio`);
} else if (target === 'remote' && this.options.remoteStream) {
this.peer?.setMediaStreamState(this.options.remoteStream, audio);
log.debug(`[Call] ${audio ? 'Enabled' : 'Disabled'} remote audio`);
}
} catch (error) {
log.error('[Call] Error handling audio media update:', error);
}
}
// Handle video changes (if supported in the future)
if (video !== undefined) {
log.debug(
`[Call] Video media update received but not yet implemented: ${target} video ${video ? 'enabled' : 'disabled'}`
);
// TODO: Implement video handling when video calling is supported
}
};
/**
* Get the Telnyx IDs associated with the call
* This method returns an object containing the Telnyx session ID, leg ID, and call control ID.
* @returns {Object} An object containing the Telnyx IDs
*/
public get telnyxIds() {
return {
telnyxSessionId: this.telnyxSessionId,
telnyxLegId: this.telnyxLegId,
telnyxCallControlId: this.telnyxCallControlId,
};
}
public invite = async () => {
this.peer = await Peer.createOffer(this.options);
this.setupTrickleIceCallbacks();
// Initialize WebRTC reporter if debug mode is enabled
if (this.debugEnabled) {
this.initializeReporter();
}
// Store the custom headers we're sending with the invite
this.inviteCustomHeaders = this.options.customHeaders || null;
try {
const msg = await this.connection.sendAndWait(
createInviteMessage({
attach: false,
callOptions: this.options,
sessionId: this.sessionId,
callId: this.callId,
sdp: this.getLocalSdpForSignaling(),
})
);
if (!isInviteACKMessage(msg)) {
throw new Error(`[Call] Invalid invite ACK message received: ${JSON.stringify(msg)}`);
}
this.callId = msg.result.callID;
if (this.isTrickleIceEnabled()) {
this.peer.flushPendingLocalCandidates();
}
} catch (error) {
this.peer.clearPendingLocalCandidates();
throw error;
}
};
private isTrickleIceEnabled = () => Boolean(this.options.peerConnectionOptions?.useTrickleIce);
private getLocalSdpForSignaling = () => {
const sdp = this.peer?.localDescription?.sdp;
if (!sdp) {
throw new Error('[Call] Local SDP not available');
}
return this.isTrickleIceEnabled() ? prepareTrickleSdp(sdp) : sdp;
};
private setupTrickleIceCallbacks = () => {
if (!this.peer || !this.isTrickleIceEnabled()) {
return;
}
this.peer.onLocalCandidate = (candidate) => {
this.sendLocalCandidate(candidate);
};
this.peer.onLocalEndOfCandidates = () => {
this.sendLocalEndOfCandidates();
};
};
private sendLocalCandidate = (candidate: TrickleIceCandidate) => {
if (!this.isTrickleIceEnabled()) {
return;
}
this.connection.send(
createCandidateMessage({
sessionId: this.sessionId,
callId: this.callId,
candidate: cleanCandidateString(candidate.candidate),
sdpMid: candidate.sdpMid,
sdpMLineIndex: candidate.sdpMLineIndex,
})
);
};
private sendLocalEndOfCandidates = () => {
if (!this.isTrickleIceEnabled()) {
return;
}
this.connection.send(
createEndOfCandidatesMessage({
sessionId: this.sessionId,
callId: this.callId,
})
);
};
public handleRemoteCandidate = (candidate: TrickleIceCandidate) => {
if (!this.peer) {
log.warn('[Call] No peer connection available for remote ICE candidate');
return;
}
this.peer.addRemoteCandidate({
...candidate,
candidate: normalizeRemoteCandidateString(candidate.candidate),
});
};
public handleRemoteEndOfCandidates = () => {
if (!this.peer) {
log.warn('[Call] No peer connection available for remote end-of-candidates');
return;
}
this.peer.addRemoteEndOfCandidates();
};
private setState = (state: CallState) => {
const prevState = this.state;
this.state = state;
// Log state change to call report collector
this.callReportCollector?.log('info', 'Call state changed', {
state,
previousState: prevState,
});
// Start stats collection when call becomes active
if (state === 'active') {
this.startCallReportCollector();
}
if (state === 'dropped') {
this.stopAndPostCallReport('dropped').catch((err) => {
log.error('[Call] Failed to stop and post call report:', err);
});
}
this.emit('telnyx.call.state', this, state);
};
/**
* Set the call to connecting state (used for push notification calls when answered via CallKit)
*/
public setConnecting = () => {
log.debug('[Call] Setting state to connecting');
this.setState('connecting');
};
/**
* Set the call to dropped state (used when network connection is lost).
* A dropped call is NOT terminated — it remains tracked and may be
* reattached when the network recovers via the reconnection flow.
*/
public setDropped = () => {
log.debug('[Call] Setting state to dropped due to network loss');
this.setState('dropped');
};
/**
* Set the call to active state (used when answer event is received)
*/
public setActive = () => {
log.debug('[Call] Setting state to active');
this.setState('active');
};
/**
* Handle remote answer SDP from answer events
* @param sdp - The SDP from the answer event
*/
public handleRemoteAnswer = (sdp: string) => {
log.debug('[Call] Handling remote answer SDP');
if (!this.peer) {
log.warn('[Call] No peer connection available for remote answer SDP');
return;
}
try {
this.peer.setRemoteDescription({ type: 'answer', sdp });
log.debug('[Call] Remote answer SDP set successfully');
} catch (error) {
log.error('[Call] Failed to set remote answer SDP:', error);
}
};
/**
* Dispose of the peer connection and clean up resources
* This should be called before creating a new peer for the same call
*/
public disposePeer = () => {
if (this.peer) {
log.debug('[Call] Disposing of existing peer connection');
this.peer.close();
this.peer = null;
log.debug('[Call] Peer connection disposed successfully');
} else {
log.debug('[Call] No peer connection to dispose');
}
};
private onSocketMessage = (msg: unknown) => {
if (isRingingEvent(msg) && msg.params.callID === this.callId) {
return this.handleRingingEvent(msg);
}
// Answer events are now handled by the TelnyxRTC client to avoid duplication
// The client will call handleRemoteAnswer() when needed
if (isByeEvent(msg) && msg.params.callID === this.callId) {
return this.handleHangupEvent(msg);
}
};
private handleRingingEvent = (msg: RingingEvent) => {
log.debug('[Call] Ringing event received', msg);
this.setState('ringing');
this.telnyxLegId = msg.params.telnyx_leg_id;
this.telnyxSessionId = msg.params.telnyx_session_id;
};
private handleHangupEvent = (msg: ByeEvent) => {
log.debug('[Call] Hangup event received', msg);
// Log call end details to report collector
this.callReportCollector?.log('info', 'Call ended', {
cause: (msg.params as any)?.cause,
causeCode: (msg.params as any)?.causeCode,
sipCode: (msg.params as any)?.sipCode,
sipReason: (msg.params as any)?.sipReason,
});
// Stop debug stats collection before ending the call
this.stopDebugStats();
this.closePeerAfterCallReport('done');
this.setState('ended');
};
// --- Call Report Collector Integration ---
private startCallReportCollector(): void {
if (!this.callReportCollector) return;
const pc = this.peer?.getPeerConnection();
if (!pc) {
log.warn('[Call] Cannot start call report collector: no peer connection');
return;
}
// Wire up peer event logging to collector
if (this.peer) {
this.peer.onPeerEventLog = (event, context) => {
this.callReportCollector?.log('info', event, context);
};
}
this.callReportCollector.start(pc as any);
// Set up intermediate flush for long calls
this.callReportCollector.onFlushNeeded = () => {
this.flushIntermediateReport();
};
}
private closePeerAfterCallReport(finalState: 'done' | 'dropped'): void {
if (!this.callReportCollector) {
this.peer?.close();
return;
}
this.stopAndPostCallReport(finalState)
.catch((err) => {
log.error('[Call] Failed to stop and post call report:', err);
})
.finally(() => {
this.peer?.close();
});
}
private async stopAndPostCallReport(finalState: 'done' | 'dropped'): Promise<void> {
if (!this.callReportCollector) return;
if (this.callReportPostingStarted) return;
this.callReportPostingStarted = true;
await this.callReportCollector.stop();
const callReportId = CALL_REPORT_ID;
if (!callReportId) {
log.warn('[Call] Cannot post call report: missing call_report_id');
return;
}
const summary = this.buildReportSummary(finalState);
const payload = this.callReportCollector.buildPayload(summary);
this.callReportCollector
.sendPayload(payload, callReportId, PROD_HOST, VOICE_SDK_ID)
.then(() => {
log.debug('[Call] Posted call report');
})
.catch((err) => {
log.error('[Call] Failed to send call report:', err);
});
}
private flushIntermediateReport(): void {
if (!this.callReportCollector) return;
const callReportId = CALL_REPORT_ID;
if (!callReportId) return;
const summary = this.buildReportSummary('active');
const payload = this.callReportCollector.flush(summary);
this.callReportCollector
.sendPayload(payload, callReportId, PROD_HOST, VOICE_SDK_ID)
.catch((err) => {
log.error('[Call] Failed to send intermediate call report:', err);
});
log.debug(`[Call] Flushed intermediate call report segment ${payload.segment}`);
}
private buildReportSummary(state: 'active' | 'done' | 'dropped'): CallReportSummary {
const endTimestamp = state === 'active' ? null : new Date().toISOString();
let durationSeconds: number | null = null;
if (endTimestamp) {
durationSeconds =
(new Date(endTimestamp).getTime() - new Date(this.callStartTimestamp).getTime()) / 1000;
durationSeconds = Math.round(durationSeconds * 100) / 100;
}
return {
callId: this.callId,
destinationNumber: this.options.destinationNumber || null,
callerNumber: this.options.callerIdNumber || null,
direction: this.direction === 'inbound' ? 'INCOMING' : 'OUTGOING',
state,
durationSeconds,
telnyxSessionId: this.telnyxSessionId,
telnyxLegId: this.telnyxLegId,
voiceSdkSessionId: this.sessionId,
sdkVersion: SDK_VERSION,
startTimestamp: this.callStartTimestamp,
endTimestamp,
};
}
}