UNPKG

@telnyx/react-native-voice-sdk

Version:

Telnyx React Native Voice SDK - A complete WebRTC voice calling solution

540 lines (469 loc) 20.4 kB
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) }; }