@telnyx/react-native-voice-sdk
Version:
Telnyx React Native Voice SDK - A complete WebRTC voice calling solution
540 lines (469 loc) • 20.4 kB
text/typescript
import NetInfo, {
NetInfoState,
NetInfoSubscription,
} from "@react-native-community/netinfo";
import { EventEmitter } from "eventemitter3";
import log from "loglevel";
import { Call } from "./call";
import type { CallOptions } from "./call-options";
import type { ClientOptions } from "./client-options";
import { Connection } from "./connection";
import { KeepAliveHandler } from "./keep-alive-handler";
import { eventBus } from "./legacy-event-bus";
import { LoginHandler } from "./login-handler";
import type { InviteEvent } from "./messages/call";
import { createInviteAckMessage, isInviteEvent } from "./messages/call";
import { createAttachCallMessage } from "./messages/attach";
import { isValidGatewayStateResponse } from "./messages/gateway";
type TelnyxRTCEvents = {
"telnyx.client.ready": () => void;
"telnyx.client.error": (error: Error) => void;
"telnyx.call.incoming": (call: Call, msg: InviteEvent) => void;
};
export class TelnyxRTC extends EventEmitter<TelnyxRTCEvents> {
public options: ClientOptions;
public call: Call | null;
public sessionId: string | null;
private connection: Connection | null;
private loginHandler: LoginHandler | null;
private keepAliveHandler: KeepAliveHandler | null;
private netInfoSubscription: NetInfoSubscription | null = null;
// Push notification support
private isCallFromPush: boolean = false;
private pushNotificationPayload: any = null;
private pendingInvite: InviteEvent | null = null;
// Pending call actions (matching iOS SDK behavior)
private pendingAnswerAction: boolean = false;
private pendingEndAction: boolean = false;
private pendingCustomHeaders: Record<string, string> = {};
constructor(opts: ClientOptions) {
super();
this.options = opts;
this.connection = null;
this.sessionId = null;
this.loginHandler = null;
this.keepAliveHandler = null;
this.call = null;
// Initialize pending actions
this.pendingAnswerAction = false;
this.pendingEndAction = false;
this.pendingCustomHeaders = {};
log.setLevel(opts.logLevel || "warn");
this.netInfoSubscription = NetInfo.addEventListener(
this.onNetInfoStateChange
);
}
/**
* Initiates a new call.
* @param options The options for the new call.
* @example
* ```typescript
* import { TelnyxRTC } from '@telnyx/react-native-voice-sdk';
* const telnyxRTC = new TelnyxRTC({ loginToken: 'your_login_token' });
* await telnyxRTC.connect();
* const call = await telnyxRTC.newCall({
* destinationNumber: '+1234567890',
* callerIdNumber: '+0987654321',
* callerIdName: 'My Name',
* customHeaders: [{ name: 'X-Custom-Header', value: 'value' }],
* });
* console.log('Call initiated:', call);
*
* @returns a Promise that resolves to the Call instance.
* @throws Error if no connection or session ID exists.
*/
public newCall = async (options: CallOptions) => {
if (!this.connection) {
log.error("[TelnyxRTC] No connection exists. Please connect first.");
throw new Error(
"[TelnyxRTC] No connection exists. Please connect first."
);
}
if (!this.sessionId) {
log.error("[TelnyxRTC] No session ID exists. Please connect first.");
throw new Error(
"[TelnyxRTC] No session ID exists. Please connect first."
);
}
this.call = new Call({
connection: this.connection,
direction: "outbound",
sessionId: this.sessionId!,
telnyxSessionId: null,
telnyxLegId: null,
callId: null,
options,
});
await this.call.invite();
return this.call;
};
/**
* Process a VoIP push notification for an incoming call.
* This should be called when a push notification is received to prepare for the incoming call.
* @param pushNotificationPayload The push notification payload containing call metadata
* @example
* ```typescript
* const telnyxRTC = new TelnyxRTC({ loginToken: 'your_login_token' });
* telnyxRTC.processVoIPNotification(pushPayload);
* await telnyxRTC.connect();
* ```
*/
public processVoIPNotification(pushNotificationPayload: any) {
log.debug("[TelnyxRTC] Processing VoIP push notification:", pushNotificationPayload);
this.isCallFromPush = true;
this.pushNotificationPayload = pushNotificationPayload;
// Extract voice_sdk_id from push notification metadata (matching iOS SDK)
const metadata = pushNotificationPayload?.metadata || pushNotificationPayload;
if (metadata?.voice_sdk_id) {
log.debug("[TelnyxRTC] Extracted voice_sdk_id from push notification:", metadata.voice_sdk_id);
// Store the voice_sdk_id for connection URL construction
(this as any)._pushVoiceSDKId = metadata.voice_sdk_id;
} else {
log.warn("[TelnyxRTC] No voice_sdk_id found in push notification payload");
}
log.debug("[TelnyxRTC] isCallFromPush flag set to:", this.isCallFromPush);
}
/**
* Queue an answer action for when the call invite arrives (matching iOS SDK behavior)
* This should be called when the user answers from CallKit before the socket connection is established
* @param customHeaders Optional custom headers to include with the answer
*/
public queueAnswerFromCallKit(customHeaders: Record<string, string> = {}) {
log.debug("[TelnyxRTC] Queuing answer action from CallKit", customHeaders);
this.pendingAnswerAction = true;
this.pendingCustomHeaders = customHeaders;
// If call already exists, answer immediately
if (this.call && this.call.state === 'ringing') {
log.debug("[TelnyxRTC] Call exists, answering immediately");
this.executePendingAnswer();
} else {
log.debug("[TelnyxRTC] Call not yet available, answer will be executed when invite arrives");
}
}
/**
* Queue an end action for when the call invite arrives (matching iOS SDK behavior)
* This should be called when the user ends from CallKit before the socket connection is established
*/
public queueEndFromCallKit() {
log.debug("[TelnyxRTC] Queuing end action from CallKit");
this.pendingEndAction = true;
// If call already exists, end immediately
if (this.call) {
log.debug("[TelnyxRTC] Call exists, ending immediately");
this.executePendingEnd();
} else {
log.debug("[TelnyxRTC] Call not yet available, end will be executed when invite arrives");
}
}
// Store push notification CallKit UUID for automatic linking
private pushNotificationCallKitUUID: string | null = null;
/**
* Set the CallKit UUID for the expected push notification call (matching iOS SDK approach)
* This should be called by CallKitHandler using the call_id from push notification metadata
*/
public setPushNotificationCallKitUUID(uuid: string | null) {
log.debug("[TelnyxRTC] Storing push notification CallKit UUID for automatic assignment:", uuid);
this.pushNotificationCallKitUUID = uuid;
}
/**
* Get the stored push notification CallKit UUID
*/
public getPushNotificationCallKitUUID(): string | null {
return this.pushNotificationCallKitUUID;
}
/**
* Execute pending answer action
*/
private async executePendingAnswer() {
if (!this.pendingAnswerAction || !this.call) {
return;
}
try {
log.debug("[TelnyxRTC] Executing pending answer action");
// Use custom answer method if available, otherwise use default
if (typeof (this.call as any).answerWithHeaders === 'function') {
await (this.call as any).answerWithHeaders(this.pendingCustomHeaders);
} else {
await this.call.answer();
}
log.debug("[TelnyxRTC] Pending answer executed successfully");
} catch (error) {
log.error("[TelnyxRTC] Failed to execute pending answer:", error);
} finally {
this.resetPendingActions();
}
}
/**
* Execute pending end action
*/
private executePendingEnd() {
if (!this.pendingEndAction || !this.call) {
return;
}
try {
log.debug("[TelnyxRTC] Executing pending end action");
this.call.hangup();
log.debug("[TelnyxRTC] Pending end executed successfully");
} catch (error) {
log.error("[TelnyxRTC] Failed to execute pending end:", error);
} finally {
this.resetPendingActions();
}
}
/**
* Reset pending action flags (matching iOS SDK resetPushVariables)
*/
private resetPendingActions() {
log.debug("[TelnyxRTC] Resetting pending actions");
this.pendingAnswerAction = false;
this.pendingEndAction = false;
this.pendingCustomHeaders = {};
this.isCallFromPush = false;
}
/**
*
* @returns A Promise that resolves when the connection is established.
* @throws Error if the connection already exists or login fails.
* @example
* ```typescript
* import { TelnyxRTC } from '@telnyx/react-native-voice-sdk';
* const telnyxRTC = new TelnyxRTC({ loginToken: 'your_login_token' });
* await telnyxRTC.connect();
* console.log('Connected to Telnyx RTC');
* ```
* Connects to the Telnyx RTC service.
* This method initializes the connection, logs in using the provided options,
* and sets up the necessary event listeners.
* It emits a 'telnyx.client.ready' event when the connection is successfully established.
* If a connection already exists, it logs a warning and does not create a new connection.
* If the login fails, it logs an error and throws an exception.
* @see {@link ClientOptions} for the options that can be passed to the constructor.
*/
public async connect() {
if (this.connection) {
log.warn(
"A connection already exists. Please close it before creating a new one."
);
return;
}
log.debug("[TelnyxRTC] Starting connection process...");
// Use custom voice_sdk_id for push notifications (matching iOS SDK behavior)
const pushVoiceSDKId = (this as any)._pushVoiceSDKId;
if (this.isCallFromPush && pushVoiceSDKId) {
log.debug("[TelnyxRTC] Creating connection with push notification voice_sdk_id:", pushVoiceSDKId);
this.connection = new Connection(pushVoiceSDKId);
} else {
log.debug("[TelnyxRTC] Creating standard connection");
this.connection = new Connection();
}
// Store reference to this client in the connection for CallKit UUID access
this.connection._client = this;
log.debug("[TelnyxRTC] Setting up connection event listeners");
this.connection.addListener("telnyx.socket.message", this.onSocketMessage);
this.connection.addListener("telnyx.socket.error", (error) => {
log.error("[TelnyxRTC] WebSocket connection error:", error);
});
this.connection.addListener("telnyx.socket.close", () => {
log.debug("[TelnyxRTC] WebSocket connection closed");
});
// Wait for WebSocket connection to be established
if (!this.connection.isConnected) {
log.debug("[TelnyxRTC] Waiting for WebSocket connection...");
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("WebSocket connection timeout after 15 seconds"));
}, 15000); // 15 second timeout
const onOpen = () => {
log.debug("[TelnyxRTC] WebSocket connection established");
clearTimeout(timeout);
this.connection!.removeListener("telnyx.socket.open", onOpen);
this.connection!.removeListener("telnyx.socket.error", onError);
this.connection!.removeListener("telnyx.socket.close", onClose);
resolve();
};
const onError = (error: Event) => {
log.error("[TelnyxRTC] WebSocket connection failed:", error);
clearTimeout(timeout);
this.connection!.removeListener("telnyx.socket.open", onOpen);
this.connection!.removeListener("telnyx.socket.error", onError);
this.connection!.removeListener("telnyx.socket.close", onClose);
reject(new Error(`WebSocket connection failed: ${error.type}`));
};
const onClose = () => {
log.debug("[TelnyxRTC] WebSocket connection closed during connection attempt");
clearTimeout(timeout);
this.connection!.removeListener("telnyx.socket.open", onOpen);
this.connection!.removeListener("telnyx.socket.error", onError);
this.connection!.removeListener("telnyx.socket.close", onClose);
reject(new Error("WebSocket connection closed unexpectedly"));
};
this.connection!.addListener("telnyx.socket.open", onOpen);
this.connection!.addListener("telnyx.socket.error", onError);
this.connection!.addListener("telnyx.socket.close", onClose);
});
}
this.loginHandler = new LoginHandler(this.connection);
this.keepAliveHandler = new KeepAliveHandler(this.connection);
this.keepAliveHandler.start();
// Set push notification flags if this is a push-initiated connection
log.debug("[TelnyxRTC] Checking isCallFromPush flag:", this.isCallFromPush);
if (this.isCallFromPush) {
log.debug("[TelnyxRTC] Setting push notification flags for login");
this.loginHandler.setAttachCall(true);
this.loginHandler.setFromPush(true);
} else {
log.debug("[TelnyxRTC] Not a push connection, no push flags needed");
}
log.debug("[TelnyxRTC] Attempting login...");
this.sessionId = await this.loginHandler.login(this.options);
if (!this.sessionId) {
log.error("Login failed. Please check your credentials and try again.");
throw new Error(
"Login failed. Please check your credentials and try again."
);
}
log.debug("[TelnyxRTC] Login successful, session ID:", this.sessionId);
// If this connection was initiated by a push notification, send attach call
if (this.isCallFromPush && this.pushNotificationPayload) {
log.debug("[TelnyxRTC] Connection initiated by push notification, sending attach call");
await this.sendAttachCall();
}
// Process any pending invite that arrived during login (matching iOS SDK behavior)
if (this.pendingInvite) {
log.debug("[TelnyxRTC] Processing pending invite received during login (matches iOS SDK)");
log.debug("[TelnyxRTC] Pending invite call ID:", this.pendingInvite.params.callID);
const pendingInvite = this.pendingInvite;
this.pendingInvite = null; // Clear the pending invite
await this.processInvite(pendingInvite);
} else {
log.debug("[TelnyxRTC] No pending invites to process");
}
this.emit("telnyx.client.ready");
}
/**
* Disconnects from the Telnyx RTC service.
* This method closes the WebSocket connection and removes all event listeners.
* If no connection exists, it logs a warning.
* @example
* ```typescript
* import { TelnyxRTC } from '@telnyx/react-native-voice-sdk';
* const telnyxRTC = new TelnyxRTC({ loginToken: 'your_login_token' });
* await telnyxRTC.connect();
* // ... use the TelnyxRTC instance ...
* telnyxRTC.disconnect();
* console.log('Disconnected from Telnyx RTC');
* ```
*/
public disconnect() {
if (!this.connection) {
log.warn("No connection exists.");
return;
}
this.connection.close();
this.connection = null;
this.removeAllListeners();
this.netInfoSubscription?.();
log.warn("[TelnyxRTC] Disconnected from Telnyx RTC");
}
public get connected() {
return this.connection !== null && this.connection.isConnected;
}
private sendAttachCall = async () => {
if (!this.connection) {
log.error("[TelnyxRTC] Cannot send attach call without connection");
return;
}
log.debug("[TelnyxRTC] Sending attach call message for push notification");
const attachMessage = createAttachCallMessage(this.pushNotificationPayload);
try {
this.connection.send(attachMessage);
log.debug("[TelnyxRTC] Attach call message sent successfully");
} catch (error) {
log.error("[TelnyxRTC] Failed to send attach call message:", error);
}
};
private onSocketMessage = (msg: unknown) => {
log.debug("[TelnyxRTC] Processing socket message:", msg);
if (isInviteEvent(msg)) {
log.debug("[TelnyxRTC] Detected invite event, processing...");
return this.handleCallInvite(msg);
}
// Check if this is an invite message that's not being detected
if (msg && typeof msg === 'object' && (msg as any).method === 'telnyx_rtc.invite') {
log.warn("[TelnyxRTC] Received invite message but isInviteEvent returned false:", msg);
}
// if (isAttachEvent(msg)) {
// return this.handleCallAttach(msg);
// }
log.debug("[TelnyxRTC] Message not processed as invite or attach");
return;
};
private handleCallInvite = async (msg: InviteEvent) => {
log.debug("[TelnyxRTC] ====== HANDLING CALL INVITE ======");
log.debug("[TelnyxRTC] Invite message:", msg);
if (!this.connection) {
log.error("[TelnyxRTC] No connection exists. Please connect first.");
throw new Error("[TelnyxRTC] Cannot receive calls without connection.");
}
// If sessionId is not available yet (during login process), store the invite for later processing
// This matches iOS SDK behavior for push notification invites received during connection
if (!this.sessionId) {
log.debug("[TelnyxRTC] Session ID not available yet, storing invite for later processing");
this.pendingInvite = msg;
// Send acknowledgment even for pending invites
log.debug("[TelnyxRTC] Sending invite acknowledgment for pending invite");
this.connection?.send(createInviteAckMessage(msg.id));
return;
}
log.debug("[TelnyxRTC] Session ID available, processing invite immediately");
await this.processInvite(msg);
};
private processInvite = async (msg: InviteEvent) => {
log.debug("[TelnyxRTC] Processing invite with session ID:", this.sessionId);
log.debug("[TelnyxRTC] Sending invite acknowledgment");
this.connection?.send(createInviteAckMessage(msg.id));
// If this is a push notification call and we have a stored CallKit UUID,
// ensure it's available for the createInboundCall method
if (this.isCallFromPush && this.pushNotificationCallKitUUID) {
log.debug("[TelnyxRTC] Push notification call - CallKit UUID will be automatically assigned during call creation:", this.pushNotificationCallKitUUID);
}
log.debug("[TelnyxRTC] Creating inbound call object");
this.call = await Call.createInboundCall({
connection: this.connection,
remoteSDP: msg.params.sdp,
sessionId: this.sessionId,
callId: msg.params.callID,
telnyxLegId: msg.params.telnyx_leg_id,
telnyxSessionId: msg.params.telnyx_session_id,
options: { destinationNumber: msg.params.caller_id_number },
});
log.debug("[TelnyxRTC] Call object created, checking for pending actions");
// Check for pending actions from CallKit (matching iOS SDK behavior)
if (this.isCallFromPush) {
log.debug("[TelnyxRTC] This is a push notification call, checking pending actions");
if (this.pendingAnswerAction) {
log.debug("[TelnyxRTC] Found pending answer action, executing...");
// Execute pending answer asynchronously to allow call setup to complete first
setTimeout(() => this.executePendingAnswer(), 100);
} else if (this.pendingEndAction) {
log.debug("[TelnyxRTC] Found pending end action, executing...");
// Execute pending end asynchronously
setTimeout(() => this.executePendingEnd(), 100);
}
}
log.debug("[TelnyxRTC] Emitting telnyx.call.incoming event");
this.emit("telnyx.call.incoming", this.call, msg);
log.debug("[TelnyxRTC] Emitting legacy event bus notification");
eventBus.emit("telnyx.notification", {
type: "callUpdate",
call: this.call,
});
log.debug("[TelnyxRTC] ====== CALL INVITE HANDLING COMPLETE ======");
};
private onNetInfoStateChange = (state: NetInfoState) => {
log.debug(
`[TelnyxRTC] Network state changed: ${state.isInternetReachable}`
);
// log.debug(VOICE_SDK_ID)
};
}